Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -81,3 +81,8 @@ FCM_LOG_CHANNEL=stack
|
||||
BAROBILL_CERT_KEY=
|
||||
BAROBILL_CORP_NUM=
|
||||
BAROBILL_TEST_MODE=true
|
||||
|
||||
# Google Cloud Storage (음성 녹음 백업)
|
||||
GCS_BUCKET_NAME=
|
||||
GCS_SERVICE_ACCOUNT_PATH=/var/www/sales/apikey/google_service_account.json
|
||||
GCS_USE_DB_CONFIG=true
|
||||
|
||||
@@ -255,6 +255,7 @@ public function sendToNts(Request $request): JsonResponse
|
||||
if ($result['success']) {
|
||||
$data['invoices'][$invoiceIndex]['status'] = 'sent';
|
||||
$data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-' . date('YmdHis');
|
||||
$data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d');
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
@@ -271,6 +272,7 @@ public function sendToNts(Request $request): JsonResponse
|
||||
// 시뮬레이션
|
||||
$data['invoices'][$invoiceIndex]['status'] = 'sent';
|
||||
$data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-SIM-' . date('YmdHis');
|
||||
$data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d');
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
@@ -495,6 +497,7 @@ private function createInvoiceRecord(array $input, string $issueKey, $apiData):
|
||||
'status' => 'issued',
|
||||
'memo' => $input['memo'] ?? '',
|
||||
'createdAt' => date('Y-m-d\TH:i:s'),
|
||||
'sentAt' => date('Y-m-d'),
|
||||
'barobillInvoiceId' => is_numeric($apiData) ? (string)$apiData : '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -120,6 +120,11 @@ public function index(Request $request): View|Response
|
||||
|
||||
/**
|
||||
* 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList)
|
||||
*
|
||||
* 바로빌 API 참고:
|
||||
* - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원)
|
||||
* - DateType: 3(전송일자) 권장
|
||||
* - 전체 조회 시 1, 3을 각각 조회하여 합침
|
||||
*/
|
||||
public function sales(Request $request): JsonResponse
|
||||
{
|
||||
@@ -128,7 +133,7 @@ public function sales(Request $request): JsonResponse
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$page = (int)$request->input('page', 1);
|
||||
$limit = (int)$request->input('limit', 50);
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세+영세, 3:면세
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
@@ -150,63 +155,78 @@ public function sales(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
// CorpNum은 callSoap에서 파트너사 사업자번호로 자동 설정
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $taxType,
|
||||
'DateType' => 1, // 1:작성일 기준
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
// TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침
|
||||
$taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType];
|
||||
$allInvoices = [];
|
||||
$totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0];
|
||||
$lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0];
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
foreach ($taxTypesToQuery as $queryTaxType) {
|
||||
$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세
|
||||
'DateType' => 3, // 3: 전송일자 기준 (권장)
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
}
|
||||
|
||||
$resultData = $result['data'];
|
||||
if (!$result['success']) {
|
||||
// 첫 번째 조회 실패 시 에러 반환
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
]);
|
||||
}
|
||||
continue; // 이미 일부 데이터가 있으면 계속 진행
|
||||
}
|
||||
|
||||
// 에러 코드 체크
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
$resultData = $result['data'];
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if ($errorCode && in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => [],
|
||||
'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
|
||||
]
|
||||
]);
|
||||
}
|
||||
// 에러 코드 체크 (데이터 없음 외의 에러)
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터 파싱
|
||||
$parsed = $this->parseInvoices($resultData, 'sales');
|
||||
// 데이터가 있는 경우 파싱
|
||||
if (!$errorCode || !in_array($errorCode, [-60005, -60001])) {
|
||||
$parsed = $this->parseInvoices($resultData, 'sales');
|
||||
$allInvoices = array_merge($allInvoices, $parsed['invoices']);
|
||||
$totalSummary['totalAmount'] += $parsed['summary']['totalAmount'];
|
||||
$totalSummary['totalTax'] += $parsed['summary']['totalTax'];
|
||||
$totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']);
|
||||
$totalSummary['count'] += $parsed['summary']['count'];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $parsed['invoices'],
|
||||
'pagination' => [
|
||||
// 페이지네이션 정보 업데이트 (마지막 조회 결과 사용)
|
||||
$lastPagination = [
|
||||
'currentPage' => $resultData->CurrentPage ?? 1,
|
||||
'countPerPage' => $resultData->CountPerPage ?? 50,
|
||||
'maxPageNum' => $resultData->MaxPageNum ?? 1,
|
||||
'maxIndex' => $resultData->MaxIndex ?? 0
|
||||
],
|
||||
'summary' => $parsed['summary']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 작성일 기준으로 정렬 (최신순)
|
||||
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $allInvoices,
|
||||
'pagination' => $lastPagination,
|
||||
'summary' => $totalSummary
|
||||
]
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -220,6 +240,11 @@ public function sales(Request $request): JsonResponse
|
||||
|
||||
/**
|
||||
* 매입 세금계산서 목록 조회 (GetPeriodTaxInvoicePurchaseList)
|
||||
*
|
||||
* 바로빌 API 참고:
|
||||
* - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원)
|
||||
* - DateType: 3(전송일자) 권장
|
||||
* - 전체 조회 시 1, 3을 각각 조회하여 합침
|
||||
*/
|
||||
public function purchases(Request $request): JsonResponse
|
||||
{
|
||||
@@ -228,7 +253,7 @@ public function purchases(Request $request): JsonResponse
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$page = (int)$request->input('page', 1);
|
||||
$limit = (int)$request->input('limit', 50);
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세+영세, 3:면세
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
@@ -250,63 +275,78 @@ public function purchases(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [
|
||||
// CorpNum은 callSoap에서 파트너사 사업자번호로 자동 설정
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $taxType,
|
||||
'DateType' => 1, // 1:작성일 기준
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
// TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침
|
||||
$taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType];
|
||||
$allInvoices = [];
|
||||
$totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0];
|
||||
$lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0];
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
foreach ($taxTypesToQuery as $queryTaxType) {
|
||||
$result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세
|
||||
'DateType' => 3, // 3: 전송일자 기준 (권장)
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
}
|
||||
|
||||
$resultData = $result['data'];
|
||||
if (!$result['success']) {
|
||||
// 첫 번째 조회 실패 시 에러 반환
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
]);
|
||||
}
|
||||
continue; // 이미 일부 데이터가 있으면 계속 진행
|
||||
}
|
||||
|
||||
// 에러 코드 체크
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
$resultData = $result['data'];
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if ($errorCode && in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => [],
|
||||
'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
|
||||
]
|
||||
]);
|
||||
}
|
||||
// 에러 코드 체크 (데이터 없음 외의 에러)
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터 파싱
|
||||
$parsed = $this->parseInvoices($resultData, 'purchase');
|
||||
// 데이터가 있는 경우 파싱
|
||||
if (!$errorCode || !in_array($errorCode, [-60005, -60001])) {
|
||||
$parsed = $this->parseInvoices($resultData, 'purchase');
|
||||
$allInvoices = array_merge($allInvoices, $parsed['invoices']);
|
||||
$totalSummary['totalAmount'] += $parsed['summary']['totalAmount'];
|
||||
$totalSummary['totalTax'] += $parsed['summary']['totalTax'];
|
||||
$totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']);
|
||||
$totalSummary['count'] += $parsed['summary']['count'];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $parsed['invoices'],
|
||||
'pagination' => [
|
||||
// 페이지네이션 정보 업데이트 (마지막 조회 결과 사용)
|
||||
$lastPagination = [
|
||||
'currentPage' => $resultData->CurrentPage ?? 1,
|
||||
'countPerPage' => $resultData->CountPerPage ?? 50,
|
||||
'maxPageNum' => $resultData->MaxPageNum ?? 1,
|
||||
'maxIndex' => $resultData->MaxIndex ?? 0
|
||||
],
|
||||
'summary' => $parsed['summary']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 작성일 기준으로 정렬 (최신순)
|
||||
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $allInvoices,
|
||||
'pagination' => $lastPagination,
|
||||
'summary' => $totalSummary
|
||||
]
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -482,10 +522,11 @@ public function diagnose(Request $request): JsonResponse
|
||||
];
|
||||
|
||||
// 테스트 2: 매출 세금계산서 조회 (기간: 최근 1개월)
|
||||
// TaxType: 1(과세+영세), 3(면세) 만 가능 / DateType: 3(전송일자) 권장
|
||||
$salesResult = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => 0,
|
||||
'DateType' => 1,
|
||||
'TaxType' => 1, // 1: 과세+영세 (0은 미지원)
|
||||
'DateType' => 3, // 3: 전송일자 기준 (권장)
|
||||
'StartDate' => date('Ymd', strtotime('-1 month')),
|
||||
'EndDate' => date('Ymd'),
|
||||
'CountPerPage' => 1,
|
||||
@@ -661,8 +702,8 @@ private function getErrorMessage(int $errorCode): string
|
||||
{
|
||||
$messages = [
|
||||
-10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.',
|
||||
-10008 => '등록되지 않은 사용자입니다 (-10008). 바로빌에 등록된 사업자번호/사용자ID를 확인해주세요.',
|
||||
-11010 => '세금계산서 조회 권한이 없습니다 (-11010). 바로빌 사이트에서 서비스 권한을 확인해주세요.',
|
||||
-10008 => '날짜형식이 잘못되었습니다 (-10008). 날짜는 YYYYMMDD 형식(하이픈 제외)으로 입력해주세요.',
|
||||
-11010 => '과세형태(TaxType)가 잘못되었습니다 (-11010). TaxType은 1(과세+영세) 또는 3(면세)만 가능합니다.',
|
||||
-24005 => 'UserID가 필요합니다 (-24005). 바로빌 회원사 ID를 설정해주세요.',
|
||||
-24006 => '조회된 데이터가 없습니다 (-24006).',
|
||||
-25005 => '조회된 데이터가 없습니다 (-25005).',
|
||||
|
||||
@@ -95,12 +95,13 @@ public function search(Request $request): JsonResponse
|
||||
$ntsService = new NtsBusinessService();
|
||||
$ntsResult = $ntsService->getBusinessStatus($companyKey);
|
||||
|
||||
// DB에 저장
|
||||
// DB에 저장 (tenant_id는 세션에서 가져옴)
|
||||
$inquiry = CreditInquiry::createFromApiResponse(
|
||||
$companyKey,
|
||||
$apiResult,
|
||||
$ntsResult,
|
||||
auth()->id()
|
||||
auth()->id(),
|
||||
session('selected_tenant_id')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
262
app/Http/Controllers/Credit/CreditUsageController.php
Normal file
262
app/Http/Controllers/Credit/CreditUsageController.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Credit;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Credit\CreditInquiry;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 신용평가 조회회수 집계 컨트롤러
|
||||
*/
|
||||
class CreditUsageController extends Controller
|
||||
{
|
||||
// 과금 정책: 월 기본 무료 제공 건수
|
||||
const FREE_MONTHLY_QUOTA = 5;
|
||||
|
||||
// 과금 정책: 추가 건당 요금 (원)
|
||||
const ADDITIONAL_FEE_PER_INQUIRY = 2000;
|
||||
|
||||
/**
|
||||
* 조회회수 집계 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('credit.usage.index'));
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$selectedTenantId = session('selected_tenant_id');
|
||||
$isHQ = $selectedTenantId == 1; // 본사(코드브릿지엑스)
|
||||
|
||||
// 기간 필터 (기본값: 현재 월)
|
||||
$year = $request->input('year', date('Y'));
|
||||
$month = $request->input('month', date('m'));
|
||||
$viewType = $request->input('view_type', 'monthly'); // monthly, yearly, custom
|
||||
|
||||
// 기간 설정
|
||||
if ($viewType === 'yearly') {
|
||||
$startDate = "{$year}-01-01 00:00:00";
|
||||
$endDate = "{$year}-12-31 23:59:59";
|
||||
} elseif ($viewType === 'custom') {
|
||||
$startDate = $request->input('start_date', date('Y-m-01')) . ' 00:00:00';
|
||||
$endDate = $request->input('end_date', date('Y-m-t')) . ' 23:59:59';
|
||||
} else {
|
||||
$startDate = "{$year}-{$month}-01 00:00:00";
|
||||
$endDate = date('Y-m-t 23:59:59', strtotime($startDate));
|
||||
}
|
||||
|
||||
// 본사는 전체 테넌트 조회, 일반 테넌트는 자기 것만
|
||||
if ($isHQ) {
|
||||
$usageData = $this->getAllTenantsUsage($startDate, $endDate, $viewType, $year);
|
||||
$tenants = Tenant::whereNull('deleted_at')
|
||||
->orderBy('company_name')
|
||||
->get(['id', 'company_name', 'code']);
|
||||
} else {
|
||||
$usageData = $this->getSingleTenantUsage($selectedTenantId, $startDate, $endDate, $viewType, $year);
|
||||
$tenants = collect();
|
||||
}
|
||||
|
||||
// 선택된 테넌트 필터
|
||||
$filterTenantId = $request->input('tenant_id');
|
||||
if ($isHQ && $filterTenantId) {
|
||||
$usageData['details'] = collect($usageData['details'])->filter(function ($item) use ($filterTenantId) {
|
||||
return $item['tenant_id'] == $filterTenantId;
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
return view('credit.usage.index', [
|
||||
'isHQ' => $isHQ,
|
||||
'usageData' => $usageData,
|
||||
'tenants' => $tenants,
|
||||
'filters' => [
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'view_type' => $viewType,
|
||||
'start_date' => substr($startDate, 0, 10),
|
||||
'end_date' => substr($endDate, 0, 10),
|
||||
'tenant_id' => $filterTenantId,
|
||||
],
|
||||
'policy' => [
|
||||
'free_quota' => self::FREE_MONTHLY_QUOTA,
|
||||
'additional_fee' => self::ADDITIONAL_FEE_PER_INQUIRY,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 테넌트 사용량 조회 (본사용)
|
||||
*/
|
||||
private function getAllTenantsUsage(string $startDate, string $endDate, string $viewType, string $year): array
|
||||
{
|
||||
// 테넌트별 조회 건수
|
||||
$query = CreditInquiry::select(
|
||||
'tenant_id',
|
||||
DB::raw('COUNT(*) as total_count'),
|
||||
DB::raw('DATE_FORMAT(inquired_at, "%Y-%m") as month')
|
||||
)
|
||||
->whereBetween('inquired_at', [$startDate, $endDate])
|
||||
->whereNotNull('tenant_id')
|
||||
->groupBy('tenant_id', DB::raw('DATE_FORMAT(inquired_at, "%Y-%m")'));
|
||||
|
||||
$rawData = $query->get();
|
||||
|
||||
// 테넌트 정보 조회
|
||||
$tenantIds = $rawData->pluck('tenant_id')->unique();
|
||||
$tenants = Tenant::whereIn('id', $tenantIds)->get()->keyBy('id');
|
||||
|
||||
// 월별로 그룹화하여 계산
|
||||
$monthlyData = [];
|
||||
foreach ($rawData as $row) {
|
||||
$tenantId = $row->tenant_id;
|
||||
$month = $row->month;
|
||||
|
||||
if (!isset($monthlyData[$tenantId])) {
|
||||
$monthlyData[$tenantId] = [];
|
||||
}
|
||||
$monthlyData[$tenantId][$month] = $row->total_count;
|
||||
}
|
||||
|
||||
// 결과 데이터 생성
|
||||
$details = [];
|
||||
$totalCount = 0;
|
||||
$totalFee = 0;
|
||||
|
||||
foreach ($monthlyData as $tenantId => $months) {
|
||||
$tenant = $tenants->get($tenantId);
|
||||
$tenantTotalCount = 0;
|
||||
$tenantTotalFee = 0;
|
||||
|
||||
foreach ($months as $month => $count) {
|
||||
$fee = $this->calculateFee($count);
|
||||
$tenantTotalCount += $count;
|
||||
$tenantTotalFee += $fee;
|
||||
|
||||
if ($viewType === 'yearly') {
|
||||
// 연간 조회 시 월별 상세 표시
|
||||
$details[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
|
||||
'tenant_code' => $tenant?->code ?? '-',
|
||||
'month' => $month,
|
||||
'count' => $count,
|
||||
'free_count' => min($count, self::FREE_MONTHLY_QUOTA),
|
||||
'paid_count' => max(0, $count - self::FREE_MONTHLY_QUOTA),
|
||||
'fee' => $fee,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($viewType !== 'yearly') {
|
||||
// 월간/기간 조회 시 테넌트별 합계만
|
||||
$totalMonthCount = array_sum($months);
|
||||
$fee = $this->calculateFee($totalMonthCount);
|
||||
|
||||
$details[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
|
||||
'tenant_code' => $tenant?->code ?? '-',
|
||||
'count' => $totalMonthCount,
|
||||
'free_count' => min($totalMonthCount, self::FREE_MONTHLY_QUOTA),
|
||||
'paid_count' => max(0, $totalMonthCount - self::FREE_MONTHLY_QUOTA),
|
||||
'fee' => $fee,
|
||||
];
|
||||
}
|
||||
|
||||
$totalCount += $tenantTotalCount;
|
||||
$totalFee += $tenantTotalFee;
|
||||
}
|
||||
|
||||
// 정렬: 조회 건수 내림차순
|
||||
usort($details, fn($a, $b) => $b['count'] - $a['count']);
|
||||
|
||||
return [
|
||||
'total_count' => $totalCount,
|
||||
'total_fee' => $totalFee,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 테넌트 사용량 조회
|
||||
*/
|
||||
private function getSingleTenantUsage(int $tenantId, string $startDate, string $endDate, string $viewType, string $year): array
|
||||
{
|
||||
$tenant = Tenant::find($tenantId);
|
||||
|
||||
// 월별 조회 건수
|
||||
$query = CreditInquiry::select(
|
||||
DB::raw('COUNT(*) as total_count'),
|
||||
DB::raw('DATE_FORMAT(inquired_at, "%Y-%m") as month')
|
||||
)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('inquired_at', [$startDate, $endDate])
|
||||
->groupBy(DB::raw('DATE_FORMAT(inquired_at, "%Y-%m")'))
|
||||
->orderBy('month');
|
||||
|
||||
$rawData = $query->get();
|
||||
|
||||
$details = [];
|
||||
$totalCount = 0;
|
||||
$totalFee = 0;
|
||||
|
||||
foreach ($rawData as $row) {
|
||||
$count = $row->total_count;
|
||||
$fee = $this->calculateFee($count);
|
||||
|
||||
$details[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
|
||||
'month' => $row->month,
|
||||
'count' => $count,
|
||||
'free_count' => min($count, self::FREE_MONTHLY_QUOTA),
|
||||
'paid_count' => max(0, $count - self::FREE_MONTHLY_QUOTA),
|
||||
'fee' => $fee,
|
||||
];
|
||||
|
||||
$totalCount += $count;
|
||||
$totalFee += $fee;
|
||||
}
|
||||
|
||||
// 연간 조회 시 없는 월도 표시
|
||||
if ($viewType === 'yearly') {
|
||||
$existingMonths = collect($details)->pluck('month')->toArray();
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$monthKey = sprintf('%s-%02d', $year, $m);
|
||||
if (!in_array($monthKey, $existingMonths)) {
|
||||
$details[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
|
||||
'month' => $monthKey,
|
||||
'count' => 0,
|
||||
'free_count' => 0,
|
||||
'paid_count' => 0,
|
||||
'fee' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
// 월 순서로 정렬
|
||||
usort($details, fn($a, $b) => strcmp($a['month'], $b['month']));
|
||||
}
|
||||
|
||||
return [
|
||||
'total_count' => $totalCount,
|
||||
'total_fee' => $totalFee,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금 계산
|
||||
*/
|
||||
private function calculateFee(int $count): int
|
||||
{
|
||||
$paidCount = max(0, $count - self::FREE_MONTHLY_QUOTA);
|
||||
return $paidCount * self::ADDITIONAL_FEE_PER_INQUIRY;
|
||||
}
|
||||
}
|
||||
381
app/Http/Controllers/Finance/SalesCommissionController.php
Normal file
381
app/Http/Controllers/Finance/SalesCommissionController.php
Normal file
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesCommission;
|
||||
use App\Models\Sales\SalesPartner;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Services\SalesCommissionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업수수료 정산 컨트롤러
|
||||
*/
|
||||
class SalesCommissionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SalesCommissionService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 정산 목록
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청 시 전체 페이지로 리다이렉트 (JavaScript 필요)
|
||||
if ($request->header('HX-Request') && !$request->header('HX-Boosted')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.sales-commissions.index'));
|
||||
}
|
||||
|
||||
// 필터 파라미터
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filters = [
|
||||
'scheduled_year' => $year,
|
||||
'scheduled_month' => $month,
|
||||
'status' => $request->input('status'),
|
||||
'payment_type' => $request->input('payment_type'),
|
||||
'partner_id' => $request->input('partner_id'),
|
||||
'search' => $request->input('search'),
|
||||
];
|
||||
|
||||
// 정산 목록
|
||||
$commissions = $this->service->getCommissions($filters);
|
||||
|
||||
// 통계
|
||||
$stats = $this->service->getSettlementStats($year, $month);
|
||||
|
||||
// 영업파트너 목록 (필터용)
|
||||
$partners = SalesPartner::with('user')
|
||||
->active()
|
||||
->orderBy('partner_code')
|
||||
->get();
|
||||
|
||||
// 입금 대기 테넌트 목록
|
||||
$pendingTenants = $this->service->getPendingPaymentTenants();
|
||||
|
||||
return view('finance.sales-commission.index', compact(
|
||||
'commissions',
|
||||
'stats',
|
||||
'partners',
|
||||
'pendingTenants',
|
||||
'year',
|
||||
'month',
|
||||
'filters'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$commission = $this->service->getCommissionById($id);
|
||||
|
||||
if (!$commission) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '정산 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $commission,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 상세 모달 (HTMX)
|
||||
*/
|
||||
public function detail(int $id): View
|
||||
{
|
||||
$commission = $this->service->getCommissionById($id);
|
||||
|
||||
return view('finance.sales-commission.partials.detail-modal', compact('commission'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 등록 (수당 생성)
|
||||
*/
|
||||
public function registerPayment(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'management_id' => 'required|integer|exists:sales_tenant_managements,id',
|
||||
'payment_type' => 'required|in:deposit,balance',
|
||||
'payment_amount' => 'required|numeric|min:0',
|
||||
'payment_date' => 'required|date',
|
||||
]);
|
||||
|
||||
try {
|
||||
$commission = $this->service->createCommission(
|
||||
$validated['management_id'],
|
||||
$validated['payment_type'],
|
||||
$validated['payment_amount'],
|
||||
$validated['payment_date']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '입금이 등록되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
public function approve(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$commission = $this->service->approve($id, auth()->id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 승인
|
||||
*/
|
||||
public function bulkApprove(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|exists:sales_commissions,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$count = $this->service->bulkApprove($validated['ids'], auth()->id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$count}건이 승인되었습니다.",
|
||||
'count' => $count,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급완료 처리
|
||||
*/
|
||||
public function markPaid(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bank_reference' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$commission = $this->service->markAsPaid($id, $validated['bank_reference'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '지급완료 처리되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 지급완료
|
||||
*/
|
||||
public function bulkMarkPaid(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|exists:sales_commissions,id',
|
||||
'bank_reference' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$count = $this->service->bulkMarkAsPaid($validated['ids'], $validated['bank_reference'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$count}건이 지급완료 처리되었습니다.",
|
||||
'count' => $count,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 처리
|
||||
*/
|
||||
public function cancel(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$commission = $this->service->cancel($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '취소되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 테이블 부분 새로고침 (HTMX)
|
||||
*/
|
||||
public function table(Request $request): View
|
||||
{
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filters = [
|
||||
'scheduled_year' => $year,
|
||||
'scheduled_month' => $month,
|
||||
'status' => $request->input('status'),
|
||||
'payment_type' => $request->input('payment_type'),
|
||||
'partner_id' => $request->input('partner_id'),
|
||||
'search' => $request->input('search'),
|
||||
];
|
||||
|
||||
$commissions = $this->service->getCommissions($filters);
|
||||
|
||||
return view('finance.sales-commission.partials.commission-table', compact('commissions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 카드 부분 새로고침 (HTMX)
|
||||
*/
|
||||
public function stats(Request $request): View
|
||||
{
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$stats = $this->service->getSettlementStats($year, $month);
|
||||
|
||||
return view('finance.sales-commission.partials.stats-cards', compact('stats', 'year', 'month'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 등록 폼 (HTMX 모달)
|
||||
*/
|
||||
public function paymentForm(Request $request): View
|
||||
{
|
||||
$managementId = $request->input('management_id');
|
||||
$management = null;
|
||||
|
||||
if ($managementId) {
|
||||
$management = SalesTenantManagement::with(['tenant', 'salesPartner.user', 'contractProducts.product'])
|
||||
->find($managementId);
|
||||
}
|
||||
|
||||
// 입금 대기 테넌트 목록
|
||||
$pendingTenants = $this->service->getPendingPaymentTenants();
|
||||
|
||||
return view('finance.sales-commission.partials.payment-form', compact('management', 'pendingTenants'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filters = [
|
||||
'scheduled_year' => $year,
|
||||
'scheduled_month' => $month,
|
||||
'status' => $request->input('status'),
|
||||
'payment_type' => $request->input('payment_type'),
|
||||
'partner_id' => $request->input('partner_id'),
|
||||
];
|
||||
|
||||
// 전체 데이터 조회 (페이지네이션 없이)
|
||||
$commissions = SalesCommission::query()
|
||||
->with(['tenant', 'partner.user', 'manager'])
|
||||
->when(!empty($filters['status']), fn($q) => $q->where('status', $filters['status']))
|
||||
->when(!empty($filters['payment_type']), fn($q) => $q->where('payment_type', $filters['payment_type']))
|
||||
->when(!empty($filters['partner_id']), fn($q) => $q->where('partner_id', $filters['partner_id']))
|
||||
->forScheduledMonth($year, $month)
|
||||
->orderBy('scheduled_payment_date')
|
||||
->get();
|
||||
|
||||
// CSV 생성
|
||||
$filename = "sales_commission_{$year}_{$month}.csv";
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($commissions) {
|
||||
$file = fopen('php://output', 'w');
|
||||
|
||||
// BOM for UTF-8
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
// 헤더
|
||||
fputcsv($file, [
|
||||
'번호', '테넌트', '입금구분', '입금액', '입금일',
|
||||
'기준액', '영업파트너', '파트너수당', '매니저', '매니저수당',
|
||||
'지급예정일', '상태', '실제지급일'
|
||||
]);
|
||||
|
||||
// 데이터
|
||||
foreach ($commissions as $commission) {
|
||||
fputcsv($file, [
|
||||
$commission->id,
|
||||
$commission->tenant->name ?? $commission->tenant->company_name,
|
||||
$commission->payment_type_label,
|
||||
number_format($commission->payment_amount),
|
||||
$commission->payment_date->format('Y-m-d'),
|
||||
number_format($commission->base_amount),
|
||||
$commission->partner?->user?->name ?? '-',
|
||||
number_format($commission->partner_commission),
|
||||
$commission->manager?->name ?? '-',
|
||||
number_format($commission->manager_commission),
|
||||
$commission->scheduled_payment_date->format('Y-m-d'),
|
||||
$commission->status_label,
|
||||
$commission->actual_payment_date?->format('Y-m-d') ?? '-',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Lab;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* R&D Labs > A. AI/자동화 메뉴 컨트롤러
|
||||
*/
|
||||
class AIController extends Controller
|
||||
{
|
||||
// 웹 녹음 AI 요약
|
||||
public function webRecording(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청 시 전체 페이지 리로드 (스크립트 로딩을 위해)
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('lab.ai.web-recording'));
|
||||
}
|
||||
|
||||
return view('lab.ai.web-recording');
|
||||
}
|
||||
|
||||
// 회의록 AI 요약
|
||||
public function meetingSummary()
|
||||
{
|
||||
return view('lab.ai.meeting-summary');
|
||||
}
|
||||
|
||||
// 업무협의록 AI 요약
|
||||
public function workMemoSummary()
|
||||
{
|
||||
return view('lab.ai.work-memo-summary');
|
||||
}
|
||||
|
||||
// 운영자용 챗봇
|
||||
public function operatorChatbot()
|
||||
{
|
||||
return view('lab.ai.operator-chatbot');
|
||||
}
|
||||
|
||||
// Vertex RAG 챗봇
|
||||
public function vertexRag()
|
||||
{
|
||||
return view('lab.ai.vertex-rag');
|
||||
}
|
||||
|
||||
// 테넌트 지식 업로드
|
||||
public function tenantKnowledge()
|
||||
{
|
||||
return view('lab.ai.tenant-knowledge');
|
||||
}
|
||||
|
||||
// 테넌트 챗봇
|
||||
public function tenantChatbot()
|
||||
{
|
||||
return view('lab.ai.tenant-chatbot');
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,6 @@ private function handlePresentationPage(Request $request, string $routeName): ?R
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세무 전략 (장기적 세무전략 프레젠테이션)
|
||||
*/
|
||||
public function tax(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('lab.strategy.tax'));
|
||||
}
|
||||
return view('lab.strategy.tax');
|
||||
}
|
||||
|
||||
/**
|
||||
* 노무 전략
|
||||
*/
|
||||
@@ -47,28 +36,6 @@ public function labor(Request $request): View|Response
|
||||
return view('lab.strategy.labor');
|
||||
}
|
||||
|
||||
/**
|
||||
* 채권추심 전략
|
||||
*/
|
||||
public function debt(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('lab.strategy.debt'));
|
||||
}
|
||||
return view('lab.strategy.debt');
|
||||
}
|
||||
|
||||
/**
|
||||
* MRP 해외사례 (presentation layout)
|
||||
*/
|
||||
public function mrpOverseas(Request $request): View|Response
|
||||
{
|
||||
if ($redirect = $this->handlePresentationPage($request, 'lab.strategy.mrp-overseas')) {
|
||||
return $redirect;
|
||||
}
|
||||
return view('lab.strategy.mrp-overseas');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담용 챗봇 전략
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Tenants\Tenant;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
|
||||
282
app/Http/Controllers/Sales/ConsultationController.php
Normal file
282
app/Http/Controllers/Sales/ConsultationController.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesConsultation;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* 상담 기록 관리 컨트롤러
|
||||
*
|
||||
* 테넌트별 상담 기록(텍스트, 음성, 파일)을 관리합니다.
|
||||
* 데이터는 sales_consultations 테이블에 저장됩니다.
|
||||
*/
|
||||
class ConsultationController extends Controller
|
||||
{
|
||||
/**
|
||||
* 상담 기록 목록 (HTMX 부분 뷰)
|
||||
*/
|
||||
public function index(int $tenantId, Request $request): View
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$scenarioType = $request->input('scenario_type', 'sales');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// DB에서 상담 기록 조회
|
||||
$consultations = SalesConsultation::getByTenantAndType($tenantId, $scenarioType, $stepId);
|
||||
|
||||
return view('sales.modals.consultation-log', [
|
||||
'tenant' => $tenant,
|
||||
'consultations' => $consultations,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => $stepId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 상담 기록 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$consultation = SalesConsultation::createText(
|
||||
$request->input('tenant_id'),
|
||||
$request->input('scenario_type'),
|
||||
$request->input('step_id'),
|
||||
$request->input('content')
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => [
|
||||
'id' => $consultation->id,
|
||||
'type' => $consultation->consultation_type,
|
||||
'content' => $consultation->content,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담 기록 삭제
|
||||
*/
|
||||
public function destroy(int $consultationId, Request $request): JsonResponse
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
// 파일이 있으면 함께 삭제
|
||||
$consultation->deleteWithFile();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 파일 업로드
|
||||
*
|
||||
* 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다.
|
||||
*/
|
||||
public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'audio' => 'required|file|mimes:webm,mp3,wav,ogg|max:51200', // 50MB
|
||||
'transcript' => 'nullable|string|max:10000',
|
||||
'duration' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$transcript = $request->input('transcript');
|
||||
$duration = $request->input('duration');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('audio');
|
||||
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||||
$localPath = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
// 10MB 이상 파일은 GCS에도 업로드 (본사 연구용)
|
||||
$gcsUri = null;
|
||||
$maxLocalSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if ($fileSize > $maxLocalSize && $gcs->isAvailable()) {
|
||||
$gcsObjectName = "consultations/{$tenantId}/{$scenarioType}/{$fileName}";
|
||||
$localFullPath = Storage::disk('local')->path($localPath);
|
||||
$gcsUri = $gcs->upload($localFullPath, $gcsObjectName);
|
||||
}
|
||||
|
||||
// DB에 저장
|
||||
$consultation = SalesConsultation::createAudio(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$localPath,
|
||||
$fileName,
|
||||
$fileSize,
|
||||
$transcript,
|
||||
$duration,
|
||||
$gcsUri
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => [
|
||||
'id' => $consultation->id,
|
||||
'type' => $consultation->consultation_type,
|
||||
'file_name' => $consultation->file_name,
|
||||
'transcript' => $consultation->transcript,
|
||||
'duration' => $consultation->duration,
|
||||
'formatted_duration' => $consultation->formatted_duration,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
'has_gcs' => !empty($gcsUri),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'file' => 'required|file|max:20480', // 20MB
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('file');
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName;
|
||||
$path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local');
|
||||
|
||||
// DB에 저장
|
||||
$consultation = SalesConsultation::createFile(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$path,
|
||||
$originalName,
|
||||
$file->getSize(),
|
||||
$file->getMimeType()
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => [
|
||||
'id' => $consultation->id,
|
||||
'type' => $consultation->consultation_type,
|
||||
'file_name' => $consultation->file_name,
|
||||
'file_size' => $consultation->file_size,
|
||||
'formatted_file_size' => $consultation->formatted_file_size,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $fileId, Request $request): JsonResponse
|
||||
{
|
||||
return $this->destroy($fileId, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 파일 다운로드
|
||||
*/
|
||||
public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
if ($consultation->consultation_type !== 'audio') {
|
||||
abort(400, '오디오 파일이 아닙니다.');
|
||||
}
|
||||
|
||||
// GCS에 저장된 경우 서명된 URL로 리다이렉트
|
||||
if ($consultation->gcs_uri) {
|
||||
$objectName = str_replace('gs://' . $gcs->getBucketName() . '/', '', $consultation->gcs_uri);
|
||||
$signedUrl = $gcs->getSignedUrl($objectName, 60);
|
||||
|
||||
if ($signedUrl) {
|
||||
return redirect()->away($signedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 파일 다운로드
|
||||
$localPath = Storage::disk('local')->path($consultation->file_path);
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$extension = pathinfo($consultation->file_name, PATHINFO_EXTENSION) ?: 'webm';
|
||||
$mimeTypes = [
|
||||
'webm' => 'audio/webm',
|
||||
'wav' => 'audio/wav',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'ogg' => 'audio/ogg',
|
||||
'm4a' => 'audio/mp4'
|
||||
];
|
||||
$contentType = $mimeTypes[$extension] ?? 'audio/webm';
|
||||
|
||||
$downloadFileName = '상담녹음_' . $consultation->created_at->format('Ymd_His') . '.' . $extension;
|
||||
|
||||
return response()->download($localPath, $downloadFileName, [
|
||||
'Content-Type' => $contentType,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $consultationId): BinaryFileResponse
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
if ($consultation->consultation_type !== 'file') {
|
||||
abort(400, '첨부파일이 아닙니다.');
|
||||
}
|
||||
|
||||
$localPath = Storage::disk('local')->path($consultation->file_path);
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return response()->download($localPath, $consultation->file_name);
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Sales/SalesContractController.php
Normal file
91
app/Http/Controllers/Sales/SalesContractController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesContractProduct;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 영업 계약관리 컨트롤러
|
||||
*/
|
||||
class SalesContractController extends Controller
|
||||
{
|
||||
/**
|
||||
* 계약 상품 저장 (전체 교체 방식)
|
||||
*/
|
||||
public function saveProducts(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|exists:tenants,id',
|
||||
'products' => 'required|array',
|
||||
'products.*.product_id' => 'required|exists:sales_products,id',
|
||||
'products.*.category_id' => 'required|exists:sales_product_categories,id',
|
||||
'products.*.registration_fee' => 'required|numeric|min:0',
|
||||
'products.*.subscription_fee' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated) {
|
||||
$tenantId = $validated['tenant_id'];
|
||||
|
||||
// 영업관리 레코드 조회 (없으면 생성)
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
// 기존 상품 삭제
|
||||
SalesContractProduct::where('tenant_id', $tenantId)->delete();
|
||||
|
||||
// 새 상품 저장
|
||||
foreach ($validated['products'] as $product) {
|
||||
SalesContractProduct::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'management_id' => $management->id,
|
||||
'category_id' => $product['category_id'],
|
||||
'product_id' => $product['product_id'],
|
||||
'registration_fee' => $product['registration_fee'],
|
||||
'subscription_fee' => $product['subscription_fee'],
|
||||
'discount_rate' => 0,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계약 상품이 저장되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '저장 중 오류가 발생했습니다.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 상품 조회
|
||||
*/
|
||||
public function getProducts(int $tenantId): JsonResponse
|
||||
{
|
||||
$products = SalesContractProduct::where('tenant_id', $tenantId)
|
||||
->with(['product', 'category'])
|
||||
->get();
|
||||
|
||||
$totals = [
|
||||
'development_fee' => $products->sum('development_fee'),
|
||||
'subscription_fee' => $products->sum('subscription_fee'),
|
||||
'count' => $products->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'products' => $products,
|
||||
'totals' => $totals,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesPartner;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Models\Sales\TenantProspect;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SalesCommissionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -11,6 +18,10 @@
|
||||
*/
|
||||
class SalesDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SalesCommissionService $commissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 대시보드 화면
|
||||
*/
|
||||
@@ -18,6 +29,9 @@ public function index(Request $request): View
|
||||
{
|
||||
$data = $this->getDashboardData($request);
|
||||
|
||||
// 영업파트너 수당 정보 추가
|
||||
$data = array_merge($data, $this->getCommissionData());
|
||||
|
||||
return view('sales.dashboard.index', $data);
|
||||
}
|
||||
|
||||
@@ -93,11 +107,39 @@ private function getDashboardData(Request $request): array
|
||||
'confirmed_commission' => 0, // 확정 가입비 수당
|
||||
];
|
||||
|
||||
// 테넌트 목록 (가망고객에서 전환된 테넌트만)
|
||||
// 전환된 가망고객의 tenant_id 목록 조회
|
||||
$convertedTenantIds = TenantProspect::whereNotNull('tenant_id')
|
||||
->where('status', TenantProspect::STATUS_CONVERTED)
|
||||
->pluck('tenant_id')
|
||||
->toArray();
|
||||
|
||||
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
|
||||
$tenants = Tenant::whereIn('id', $convertedTenantIds)
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// 각 테넌트의 영업 관리 정보 로드
|
||||
$tenantIds = $tenants->pluck('id')->toArray();
|
||||
$managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds)
|
||||
->with('manager')
|
||||
->get()
|
||||
->keyBy('tenant_id');
|
||||
|
||||
// 내가 유치한 영업파트너 목록 (드롭다운용)
|
||||
$allManagers = auth()->user()->children()
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return compact(
|
||||
'stats',
|
||||
'commissionByRole',
|
||||
'totalCommissionRatio',
|
||||
'tenantStats',
|
||||
'tenants',
|
||||
'managements',
|
||||
'allManagers',
|
||||
'period',
|
||||
'year',
|
||||
'month',
|
||||
@@ -105,4 +147,123 @@ private function getDashboardData(Request $request): array
|
||||
'endDate'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 지정 변경
|
||||
*/
|
||||
public function assignManager(int $tenantId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'manager_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$managerId = $request->input('manager_id');
|
||||
|
||||
// 테넌트 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
if ($managerId === 0) {
|
||||
// 본인으로 설정 (현재 로그인 사용자)
|
||||
$manager = auth()->user();
|
||||
$management->update([
|
||||
'manager_user_id' => $manager->id,
|
||||
]);
|
||||
} else {
|
||||
// 특정 매니저 지정
|
||||
$manager = User::find($managerId);
|
||||
if (!$manager) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '매니저를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$management->update([
|
||||
'manager_user_id' => $manager->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'manager' => [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 리스트 부분 새로고침 (HTMX)
|
||||
*/
|
||||
public function refreshTenantList(Request $request): View
|
||||
{
|
||||
// 전환된 가망고객의 tenant_id 목록 조회
|
||||
$convertedTenantIds = TenantProspect::whereNotNull('tenant_id')
|
||||
->where('status', TenantProspect::STATUS_CONVERTED)
|
||||
->pluck('tenant_id')
|
||||
->toArray();
|
||||
|
||||
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
|
||||
$tenants = Tenant::whereIn('id', $convertedTenantIds)
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// 각 테넌트의 영업 관리 정보 로드
|
||||
$tenantIds = $tenants->pluck('id')->toArray();
|
||||
$managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds)
|
||||
->with('manager')
|
||||
->get()
|
||||
->keyBy('tenant_id');
|
||||
|
||||
// 내가 유치한 영업파트너 목록 (드롭다운용)
|
||||
$allManagers = auth()->user()->children()
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return view('sales.dashboard.partials.tenant-list', compact(
|
||||
'tenants',
|
||||
'managements',
|
||||
'allManagers'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 목록 조회 (드롭다운용)
|
||||
*/
|
||||
public function getManagers(Request $request): JsonResponse
|
||||
{
|
||||
// HQ 테넌트의 사용자 중 매니저 역할이 있는 사용자 조회
|
||||
$managers = User::whereHas('tenants', function ($query) {
|
||||
$query->where('tenant_type', 'HQ');
|
||||
})->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'managers' => $managers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업파트너 수당 정보 조회
|
||||
*/
|
||||
private function getCommissionData(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$commissionSummary = [];
|
||||
$recentCommissions = collect();
|
||||
|
||||
// 현재 사용자가 영업파트너인지 확인
|
||||
$partner = SalesPartner::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($partner) {
|
||||
$commissionSummary = $this->commissionService->getPartnerCommissionSummary($partner->id);
|
||||
$recentCommissions = $this->commissionService->getRecentCommissions($partner->id, 5);
|
||||
}
|
||||
|
||||
return compact('commissionSummary', 'recentCommissions', 'partner');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +73,9 @@ public function store(Request $request)
|
||||
'documents.*.description' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// 등록자가 영업파트너인 경우 자동으로 추천인(parent)으로 설정
|
||||
// 본사 관리자가 등록하는 경우 parent_id는 null (최상위 파트너)
|
||||
$currentUser = auth()->user();
|
||||
$isSalesPartner = $currentUser->userRoles()
|
||||
->whereHas('role', fn($q) => $q->whereIn('name', ['sales', 'manager', 'recruiter']))
|
||||
->exists();
|
||||
|
||||
$validated['parent_id'] = $isSalesPartner ? $currentUser->id : null;
|
||||
// 등록자를 추천인(parent)으로 자동 설정
|
||||
// 본사 관리자가 등록해도 해당 관리자가 추천인이 됨
|
||||
$validated['parent_id'] = auth()->id();
|
||||
|
||||
// 문서 배열 구성
|
||||
$documents = [];
|
||||
@@ -122,6 +117,38 @@ public function show(int $id): View
|
||||
return view('sales.managers.show', compact('partner', 'level', 'children', 'delegationCandidates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 모달용
|
||||
*/
|
||||
public function modalShow(int $id): View
|
||||
{
|
||||
$partner = User::with(['parent', 'children', 'userRoles.role', 'salesDocuments', 'approver'])
|
||||
->findOrFail($id);
|
||||
|
||||
$level = $this->service->getPartnerLevel($partner);
|
||||
|
||||
$children = User::where('parent_id', $partner->id)
|
||||
->with('userRoles.role')
|
||||
->get();
|
||||
|
||||
return view('sales.managers.partials.show-modal', compact('partner', 'level', 'children'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 모달용
|
||||
*/
|
||||
public function modalEdit(int $id): View
|
||||
{
|
||||
$partner = User::with(['userRoles.role', 'salesDocuments', 'parent'])->findOrFail($id);
|
||||
$roles = $this->service->getSalesRoles();
|
||||
$currentRoleIds = $partner->userRoles->pluck('role_id')->toArray();
|
||||
$documentTypes = SalesManagerDocument::DOCUMENT_TYPES;
|
||||
|
||||
return view('sales.managers.partials.edit-modal', compact(
|
||||
'partner', 'roles', 'currentRoleIds', 'documentTypes'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 폼
|
||||
*/
|
||||
|
||||
279
app/Http/Controllers/Sales/SalesProductController.php
Normal file
279
app/Http/Controllers/Sales/SalesProductController.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesProduct;
|
||||
use App\Models\Sales\SalesProductCategory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업 상품관리 컨트롤러 (HQ 전용)
|
||||
*/
|
||||
class SalesProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* 상품관리 메인 화면
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청인 경우 전체 페이지 리로드 (Alpine.js 스크립트 실행 필요)
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('sales.products.index'));
|
||||
}
|
||||
|
||||
$categories = SalesProductCategory::active()
|
||||
->ordered()
|
||||
->with(['products' => fn($q) => $q->ordered()])
|
||||
->get();
|
||||
|
||||
$currentCategoryCode = $request->input('category', $categories->first()?->code);
|
||||
$currentCategory = $categories->firstWhere('code', $currentCategoryCode) ?? $categories->first();
|
||||
|
||||
return view('sales.products.index', compact('categories', 'currentCategory'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록 (HTMX용)
|
||||
*/
|
||||
public function productList(Request $request): View
|
||||
{
|
||||
$categoryCode = $request->input('category');
|
||||
$category = SalesProductCategory::where('code', $categoryCode)
|
||||
->with(['products' => fn($q) => $q->ordered()])
|
||||
->first();
|
||||
|
||||
return view('sales.products.partials.product-list', compact('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category_id' => 'required|exists:sales_product_categories,id',
|
||||
'code' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'development_fee' => 'required|numeric|min:0',
|
||||
'registration_fee' => 'required|numeric|min:0',
|
||||
'subscription_fee' => 'required|numeric|min:0',
|
||||
'partner_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'manager_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'allow_flexible_pricing' => 'boolean',
|
||||
'is_required' => 'boolean',
|
||||
]);
|
||||
|
||||
// 코드 중복 체크
|
||||
$exists = SalesProduct::where('category_id', $validated['category_id'])
|
||||
->where('code', $validated['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 존재하는 상품 코드입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 순서 설정 (마지막)
|
||||
$maxOrder = SalesProduct::where('category_id', $validated['category_id'])->max('display_order') ?? 0;
|
||||
$validated['display_order'] = $maxOrder + 1;
|
||||
$validated['partner_commission_rate'] = $validated['partner_commission_rate'] ?? 20.00;
|
||||
$validated['manager_commission_rate'] = $validated['manager_commission_rate'] ?? 5.00;
|
||||
|
||||
$product = SalesProduct::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상품이 등록되었습니다.',
|
||||
'product' => $product,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$product = SalesProduct::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'development_fee' => 'sometimes|numeric|min:0',
|
||||
'registration_fee' => 'sometimes|numeric|min:0',
|
||||
'subscription_fee' => 'sometimes|numeric|min:0',
|
||||
'partner_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'manager_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'allow_flexible_pricing' => 'boolean',
|
||||
'is_required' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$product->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상품이 수정되었습니다.',
|
||||
'product' => $product->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 삭제 (soft delete)
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$product = SalesProduct::findOrFail($id);
|
||||
$product->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상품이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화 토글
|
||||
*/
|
||||
public function toggleActive(int $id): JsonResponse
|
||||
{
|
||||
$product = SalesProduct::findOrFail($id);
|
||||
$product->update(['is_active' => !$product->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $product->is_active ? '상품이 활성화되었습니다.' : '상품이 비활성화되었습니다.',
|
||||
'is_active' => $product->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'orders' => 'required|array',
|
||||
'orders.*.id' => 'required|exists:sales_products,id',
|
||||
'orders.*.order' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
foreach ($validated['orders'] as $item) {
|
||||
SalesProduct::where('id', $item['id'])->update(['display_order' => $item['order']]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== 카테고리 관리 ====================
|
||||
|
||||
/**
|
||||
* 카테고리 목록
|
||||
*/
|
||||
public function categories(): JsonResponse
|
||||
{
|
||||
$categories = SalesProductCategory::ordered()->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
public function storeCategory(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:50|unique:sales_product_categories,code',
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'base_storage' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$maxOrder = SalesProductCategory::max('display_order') ?? 0;
|
||||
$validated['display_order'] = $maxOrder + 1;
|
||||
|
||||
$category = SalesProductCategory::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 생성되었습니다.',
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
*/
|
||||
public function updateCategory(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$category = SalesProductCategory::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'base_storage' => 'nullable|string|max:20',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 수정되었습니다.',
|
||||
'category' => $category->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
*/
|
||||
public function deleteCategory(int $id): JsonResponse
|
||||
{
|
||||
$category = SalesProductCategory::findOrFail($id);
|
||||
|
||||
// 상품이 있으면 삭제 불가
|
||||
if ($category->products()->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '상품이 있는 카테고리는 삭제할 수 없습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== API (영업 시나리오용) ====================
|
||||
|
||||
/**
|
||||
* 상품 목록 API (카테고리 포함)
|
||||
*/
|
||||
public function getProductsApi(): JsonResponse
|
||||
{
|
||||
$categories = SalesProductCategory::active()
|
||||
->ordered()
|
||||
->with(['products' => fn($q) => $q->active()->ordered()])
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
167
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesScenarioChecklist;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 관리 컨트롤러
|
||||
*
|
||||
* 영업 진행 및 매니저 상담 프로세스의 시나리오 모달과 체크리스트를 관리합니다.
|
||||
* 데이터는 sales_scenario_checklists 테이블에 저장됩니다.
|
||||
*/
|
||||
class SalesScenarioController extends Controller
|
||||
{
|
||||
/**
|
||||
* 영업 시나리오 모달 뷰
|
||||
*/
|
||||
public function salesScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.sales_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 테넌트 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
// 체크리스트 진행 상태 조회 (DB에서)
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, 'sales', $steps);
|
||||
|
||||
// 진행률 업데이트
|
||||
$management->updateProgress('sales', $progress['percentage']);
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 시나리오 모달 뷰
|
||||
*/
|
||||
public function managerScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.manager_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 테넌트 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
// 체크리스트 진행 상태 조회 (DB에서)
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, 'manager', $steps);
|
||||
|
||||
// 진행률 업데이트
|
||||
$management->updateProgress('manager', $progress['percentage']);
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 항목 토글 (HTMX)
|
||||
*/
|
||||
public function toggleChecklist(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'required|integer',
|
||||
'checkpoint_id' => 'required|string',
|
||||
'checked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$checkpointId = $request->input('checkpoint_id');
|
||||
$checked = $request->boolean('checked');
|
||||
|
||||
// 체크리스트 토글 (DB에 저장)
|
||||
SalesScenarioChecklist::toggle(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$checkpointId,
|
||||
$checked,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 진행률 재계산
|
||||
$steps = config("sales_scenario.{$scenarioType}_steps");
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, $scenarioType, $steps);
|
||||
|
||||
// 테넌트 영업 관리 정보 업데이트
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
$management->updateProgress($scenarioType, $progress['percentage']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 조회
|
||||
*/
|
||||
public function getProgress(int $tenantId, string $type): JsonResponse
|
||||
{
|
||||
$steps = config("sales_scenario.{$type}_steps");
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, $type, $steps);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ public function store(Request $request)
|
||||
'contact_email' => 'nullable|email|max:100',
|
||||
'address' => 'nullable|string|max:500',
|
||||
'business_card' => 'nullable|image|max:5120',
|
||||
'business_card_image_data' => 'nullable|string',
|
||||
'memo' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
@@ -79,9 +80,14 @@ public function store(Request $request)
|
||||
// 등록자는 현재 로그인 사용자
|
||||
$validated['registered_by'] = auth()->id();
|
||||
|
||||
// Base64 이미지 데이터가 있으면 전달
|
||||
$businessCardBase64 = $validated['business_card_image_data'] ?? null;
|
||||
unset($validated['business_card_image_data']);
|
||||
|
||||
$this->service->register(
|
||||
$validated,
|
||||
$request->file('business_card')
|
||||
$request->file('business_card'),
|
||||
$businessCardBase64
|
||||
);
|
||||
|
||||
return redirect()->route('sales.prospects.index')
|
||||
@@ -214,6 +220,27 @@ public function checkBusinessNumber(Request $request)
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달용 상세 정보
|
||||
*/
|
||||
public function modalShow(int $id): View
|
||||
{
|
||||
$prospect = TenantProspect::with(['registeredBy', 'tenant', 'convertedBy'])
|
||||
->findOrFail($id);
|
||||
|
||||
return view('sales.prospects.partials.show-modal', compact('prospect'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달용 수정 폼
|
||||
*/
|
||||
public function modalEdit(int $id): View
|
||||
{
|
||||
$prospect = TenantProspect::findOrFail($id);
|
||||
|
||||
return view('sales.prospects.partials.edit-modal', compact('prospect'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 이미지 삭제 (AJAX)
|
||||
*/
|
||||
|
||||
@@ -20,12 +20,25 @@ public function index(Request $request): View|Response
|
||||
return response('', 200)->header('HX-Redirect', route('system.ai-config.index'));
|
||||
}
|
||||
|
||||
$configs = AiConfig::orderBy('provider')
|
||||
// AI 설정 (gemini, claude, openai)
|
||||
$aiConfigs = AiConfig::whereIn('provider', AiConfig::AI_PROVIDERS)
|
||||
->orderBy('provider')
|
||||
->orderByDesc('is_active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('system.ai-config.index', compact('configs'));
|
||||
// 스토리지 설정 (gcs)
|
||||
$storageConfigs = AiConfig::whereIn('provider', AiConfig::STORAGE_PROVIDERS)
|
||||
->orderBy('provider')
|
||||
->orderByDesc('is_active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('system.ai-config.index', [
|
||||
'configs' => $aiConfigs,
|
||||
'aiConfigs' => $aiConfigs,
|
||||
'storageConfigs' => $storageConfigs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,26 +48,40 @@ public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'provider' => 'required|string|in:gemini,claude,openai,gcs',
|
||||
'api_key' => 'nullable|string|max:255',
|
||||
'model' => 'required|string|max:100',
|
||||
'model' => 'nullable|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'nullable|array',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account',
|
||||
'options.project_id' => 'nullable|string|max:100',
|
||||
'options.region' => 'nullable|string|max:50',
|
||||
'options.service_account_path' => 'nullable|string|max:500',
|
||||
'options.bucket_name' => 'nullable|string|max:200',
|
||||
'options.service_account_json' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
// GCS의 경우 별도 검증
|
||||
if ($validated['provider'] === 'gcs') {
|
||||
if (empty($validated['options']['bucket_name'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '버킷 이름을 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
$validated['model'] = '-'; // GCS는 모델 불필요
|
||||
$validated['api_key'] = 'gcs_service_account'; // DB NOT NULL 제약
|
||||
} else {
|
||||
// AI 설정: Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
@@ -81,26 +108,40 @@ public function update(Request $request, int $id): JsonResponse
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'provider' => 'required|string|in:gemini,claude,openai,gcs',
|
||||
'api_key' => 'nullable|string|max:255',
|
||||
'model' => 'required|string|max:100',
|
||||
'model' => 'nullable|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'nullable|array',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account',
|
||||
'options.project_id' => 'nullable|string|max:100',
|
||||
'options.region' => 'nullable|string|max:50',
|
||||
'options.service_account_path' => 'nullable|string|max:500',
|
||||
'options.bucket_name' => 'nullable|string|max:200',
|
||||
'options.service_account_json' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
// GCS의 경우 별도 검증
|
||||
if ($validated['provider'] === 'gcs') {
|
||||
if (empty($validated['options']['bucket_name'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '버킷 이름을 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
$validated['model'] = '-';
|
||||
$validated['api_key'] = 'gcs_service_account';
|
||||
} else {
|
||||
// AI 설정: Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
@@ -163,19 +204,35 @@ public function test(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'api_key' => 'required|string',
|
||||
'api_key' => 'nullable|string',
|
||||
'model' => 'required|string',
|
||||
'base_url' => 'nullable|string',
|
||||
'auth_type' => 'nullable|string|in:api_key,vertex_ai',
|
||||
'project_id' => 'nullable|string',
|
||||
'region' => 'nullable|string',
|
||||
'service_account_path' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$provider = $validated['provider'];
|
||||
$apiKey = $validated['api_key'];
|
||||
$model = $validated['model'];
|
||||
$baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider];
|
||||
$authType = $validated['auth_type'] ?? 'api_key';
|
||||
|
||||
if ($provider === 'gemini') {
|
||||
$result = $this->testGemini($baseUrl, $model, $apiKey);
|
||||
if ($authType === 'vertex_ai') {
|
||||
// Vertex AI (서비스 계정) 방식
|
||||
$result = $this->testGeminiVertexAi(
|
||||
$model,
|
||||
$validated['project_id'] ?? '',
|
||||
$validated['region'] ?? 'us-central1',
|
||||
$validated['service_account_path'] ?? ''
|
||||
);
|
||||
} else {
|
||||
// API 키 방식
|
||||
$apiKey = $validated['api_key'] ?? '';
|
||||
$baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider];
|
||||
$result = $this->testGemini($baseUrl, $model, $apiKey);
|
||||
}
|
||||
} else {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
@@ -193,7 +250,7 @@ public function test(Request $request): JsonResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 테스트
|
||||
* Gemini API 테스트 (API 키 방식)
|
||||
*/
|
||||
private function testGemini(string $baseUrl, string $model, string $apiKey): array
|
||||
{
|
||||
@@ -225,4 +282,223 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr
|
||||
'error' => 'API 응답 오류: ' . $response->status(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 테스트 (Vertex AI 방식)
|
||||
*/
|
||||
private function testGeminiVertexAi(string $model, string $projectId, string $region, string $serviceAccountPath): array
|
||||
{
|
||||
// 필수 파라미터 검증
|
||||
if (empty($projectId)) {
|
||||
return ['ok' => false, 'error' => '프로젝트 ID가 필요합니다.'];
|
||||
}
|
||||
|
||||
if (empty($serviceAccountPath)) {
|
||||
return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.'];
|
||||
}
|
||||
|
||||
if (!file_exists($serviceAccountPath)) {
|
||||
return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"];
|
||||
}
|
||||
|
||||
// 서비스 계정 JSON 로드
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (!$serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) {
|
||||
return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.'];
|
||||
}
|
||||
|
||||
// OAuth 토큰 획득
|
||||
$accessToken = $this->getVertexAiAccessToken($serviceAccount);
|
||||
if (!$accessToken) {
|
||||
return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.'];
|
||||
}
|
||||
|
||||
// Vertex AI 엔드포인트 URL 구성
|
||||
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(30)
|
||||
->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->post($url, [
|
||||
'contents' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'parts' => [
|
||||
['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 10,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Vertex AI 연결 테스트 성공',
|
||||
];
|
||||
}
|
||||
|
||||
// 상세 오류 메시지 추출
|
||||
$errorBody = $response->json();
|
||||
$errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status());
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => "Vertex AI 오류: {$errorMsg}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex AI OAuth 토큰 획득
|
||||
*/
|
||||
private function getVertexAiAccessToken(array $serviceAccount): ?string
|
||||
{
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 연결 테스트
|
||||
*/
|
||||
public function testGcs(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bucket_name' => 'required|string',
|
||||
'service_account_path' => 'nullable|string',
|
||||
'service_account_json' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$bucketName = $validated['bucket_name'];
|
||||
$serviceAccount = null;
|
||||
|
||||
// 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로)
|
||||
if (!empty($validated['service_account_json'])) {
|
||||
$serviceAccount = $validated['service_account_json'];
|
||||
} elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) {
|
||||
$serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true);
|
||||
}
|
||||
|
||||
if (!$serviceAccount) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '서비스 계정 정보를 찾을 수 없습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// OAuth 토큰 획득
|
||||
$accessToken = $this->getGcsAccessToken($serviceAccount);
|
||||
if (!$accessToken) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'OAuth 토큰 획득 실패',
|
||||
]);
|
||||
}
|
||||
|
||||
// 버킷 존재 확인
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(10)
|
||||
->withHeaders(['Authorization' => 'Bearer ' . $accessToken])
|
||||
->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "GCS 연결 성공! 버킷: {$bucketName}",
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '버킷 접근 실패: ' . $response->status(),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS OAuth 토큰 획득
|
||||
*/
|
||||
private function getGcsAccessToken(array $serviceAccount): ?string
|
||||
{
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL 인코딩
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
class CreditInquiry extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'inquiry_key',
|
||||
'company_key',
|
||||
'company_name',
|
||||
@@ -175,12 +176,14 @@ public function getNtsStatusLabelAttribute(): string
|
||||
* @param array $apiResult 쿠콘 API 결과
|
||||
* @param array|null $ntsResult 국세청 API 결과
|
||||
* @param int|null $userId 조회자 ID
|
||||
* @param int|null $tenantId 테넌트 ID
|
||||
*/
|
||||
public static function createFromApiResponse(
|
||||
string $companyKey,
|
||||
array $apiResult,
|
||||
?array $ntsResult = null,
|
||||
?int $userId = null
|
||||
?int $userId = null,
|
||||
?int $tenantId = null
|
||||
): self {
|
||||
// 요약 정보에서 건수 추출
|
||||
$summaryData = $apiResult['summary']['data'] ?? [];
|
||||
@@ -238,6 +241,7 @@ public static function createFromApiResponse(
|
||||
};
|
||||
|
||||
return self::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'company_key' => $companyKey,
|
||||
'user_id' => $userId,
|
||||
'inquired_at' => now(),
|
||||
|
||||
293
app/Models/Sales/SalesCommission.php
Normal file
293
app/Models/Sales/SalesCommission.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 영업수수료 정산 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $management_id
|
||||
* @property string $payment_type
|
||||
* @property float $payment_amount
|
||||
* @property string $payment_date
|
||||
* @property float $base_amount
|
||||
* @property float $partner_rate
|
||||
* @property float $manager_rate
|
||||
* @property float $partner_commission
|
||||
* @property float $manager_commission
|
||||
* @property string $scheduled_payment_date
|
||||
* @property string $status
|
||||
* @property string|null $actual_payment_date
|
||||
* @property int $partner_id
|
||||
* @property int|null $manager_user_id
|
||||
* @property string|null $notes
|
||||
* @property string|null $bank_reference
|
||||
* @property int|null $approved_by
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
*/
|
||||
class SalesCommission extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_commissions';
|
||||
|
||||
/**
|
||||
* 상태 상수
|
||||
*/
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_APPROVED = 'approved';
|
||||
const STATUS_PAID = 'paid';
|
||||
const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
/**
|
||||
* 입금 구분 상수
|
||||
*/
|
||||
const PAYMENT_DEPOSIT = 'deposit';
|
||||
const PAYMENT_BALANCE = 'balance';
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
public static array $statusLabels = [
|
||||
self::STATUS_PENDING => '대기',
|
||||
self::STATUS_APPROVED => '승인',
|
||||
self::STATUS_PAID => '지급완료',
|
||||
self::STATUS_CANCELLED => '취소',
|
||||
];
|
||||
|
||||
/**
|
||||
* 입금 구분 라벨
|
||||
*/
|
||||
public static array $paymentTypeLabels = [
|
||||
self::PAYMENT_DEPOSIT => '계약금',
|
||||
self::PAYMENT_BALANCE => '잔금',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'management_id',
|
||||
'payment_type',
|
||||
'payment_amount',
|
||||
'payment_date',
|
||||
'base_amount',
|
||||
'partner_rate',
|
||||
'manager_rate',
|
||||
'partner_commission',
|
||||
'manager_commission',
|
||||
'scheduled_payment_date',
|
||||
'status',
|
||||
'actual_payment_date',
|
||||
'partner_id',
|
||||
'manager_user_id',
|
||||
'notes',
|
||||
'bank_reference',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payment_amount' => 'decimal:2',
|
||||
'base_amount' => 'decimal:2',
|
||||
'partner_rate' => 'decimal:2',
|
||||
'manager_rate' => 'decimal:2',
|
||||
'partner_commission' => 'decimal:2',
|
||||
'manager_commission' => 'decimal:2',
|
||||
'payment_date' => 'date',
|
||||
'scheduled_payment_date' => 'date',
|
||||
'actual_payment_date' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업관리 관계
|
||||
*/
|
||||
public function management(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTenantManagement::class, 'management_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업파트너 관계
|
||||
*/
|
||||
public function partner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesPartner::class, 'partner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저(사용자) 관계
|
||||
*/
|
||||
public function manager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'manager_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 내역 관계
|
||||
*/
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesCommissionDetail::class, 'commission_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인자 관계
|
||||
*/
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨 Accessor
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::$statusLabels[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 구분 라벨 Accessor
|
||||
*/
|
||||
public function getPaymentTypeLabelAttribute(): string
|
||||
{
|
||||
return self::$paymentTypeLabels[$this->payment_type] ?? $this->payment_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 수당액 Accessor
|
||||
*/
|
||||
public function getTotalCommissionAttribute(): float
|
||||
{
|
||||
return $this->partner_commission + $this->manager_commission;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급예정일 계산 (입금일 익월 10일)
|
||||
*/
|
||||
public static function calculateScheduledPaymentDate(Carbon $paymentDate): Carbon
|
||||
{
|
||||
return $paymentDate->copy()->addMonth()->day(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
public function approve(int $approverId): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_PENDING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->update([
|
||||
'status' => self::STATUS_APPROVED,
|
||||
'approved_by' => $approverId,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급완료 처리
|
||||
*/
|
||||
public function markAsPaid(?string $bankReference = null): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_APPROVED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->update([
|
||||
'status' => self::STATUS_PAID,
|
||||
'actual_payment_date' => now()->format('Y-m-d'),
|
||||
'bank_reference' => $bankReference,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 처리
|
||||
*/
|
||||
public function cancel(): bool
|
||||
{
|
||||
if ($this->status === self::STATUS_PAID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->update([
|
||||
'status' => self::STATUS_CANCELLED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대기 상태 스코프
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인완료 스코프
|
||||
*/
|
||||
public function scopeApproved(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급완료 스코프
|
||||
*/
|
||||
public function scopePaid(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PAID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 영업파트너 스코프
|
||||
*/
|
||||
public function scopeForPartner(Builder $query, int $partnerId): Builder
|
||||
{
|
||||
return $query->where('partner_id', $partnerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 매니저 스코프
|
||||
*/
|
||||
public function scopeForManager(Builder $query, int $managerUserId): Builder
|
||||
{
|
||||
return $query->where('manager_user_id', $managerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 월 지급예정 스코프
|
||||
*/
|
||||
public function scopeForScheduledMonth(Builder $query, int $year, int $month): Builder
|
||||
{
|
||||
return $query->whereYear('scheduled_payment_date', $year)
|
||||
->whereMonth('scheduled_payment_date', $month);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 기간 입금 스코프
|
||||
*/
|
||||
public function scopePaymentDateBetween(Builder $query, string $startDate, string $endDate): Builder
|
||||
{
|
||||
return $query->whereBetween('payment_date', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
68
app/Models/Sales/SalesCommissionDetail.php
Normal file
68
app/Models/Sales/SalesCommissionDetail.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 영업수수료 상세 모델 (상품별 수당 내역)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $commission_id
|
||||
* @property int $contract_product_id
|
||||
* @property float $registration_fee
|
||||
* @property float $base_amount
|
||||
* @property float $partner_rate
|
||||
* @property float $manager_rate
|
||||
* @property float $partner_commission
|
||||
* @property float $manager_commission
|
||||
*/
|
||||
class SalesCommissionDetail extends Model
|
||||
{
|
||||
protected $table = 'sales_commission_details';
|
||||
|
||||
protected $fillable = [
|
||||
'commission_id',
|
||||
'contract_product_id',
|
||||
'registration_fee',
|
||||
'base_amount',
|
||||
'partner_rate',
|
||||
'manager_rate',
|
||||
'partner_commission',
|
||||
'manager_commission',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'registration_fee' => 'decimal:2',
|
||||
'base_amount' => 'decimal:2',
|
||||
'partner_rate' => 'decimal:2',
|
||||
'manager_rate' => 'decimal:2',
|
||||
'partner_commission' => 'decimal:2',
|
||||
'manager_commission' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 수수료 정산 관계
|
||||
*/
|
||||
public function commission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesCommission::class, 'commission_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 상품 관계
|
||||
*/
|
||||
public function contractProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesContractProduct::class, 'contract_product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 수당액 Accessor
|
||||
*/
|
||||
public function getTotalCommissionAttribute(): float
|
||||
{
|
||||
return $this->partner_commission + $this->manager_commission;
|
||||
}
|
||||
}
|
||||
261
app/Models/Sales/SalesConsultation.php
Normal file
261
app/Models/Sales/SalesConsultation.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 영업 상담 기록 모델 (텍스트, 음성, 첨부파일)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $scenario_type (sales/manager)
|
||||
* @property int|null $step_id
|
||||
* @property string $consultation_type (text/audio/file)
|
||||
* @property string|null $content
|
||||
* @property string|null $file_path
|
||||
* @property string|null $file_name
|
||||
* @property int|null $file_size
|
||||
* @property string|null $file_type
|
||||
* @property string|null $transcript
|
||||
* @property int|null $duration
|
||||
* @property int $created_by
|
||||
*/
|
||||
class SalesConsultation extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_consultations';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'scenario_type',
|
||||
'step_id',
|
||||
'consultation_type',
|
||||
'content',
|
||||
'file_path',
|
||||
'file_name',
|
||||
'file_size',
|
||||
'file_type',
|
||||
'transcript',
|
||||
'duration',
|
||||
'gcs_uri',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'step_id' => 'integer',
|
||||
'file_size' => 'integer',
|
||||
'duration' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 상담 유형 상수
|
||||
*/
|
||||
const TYPE_TEXT = 'text';
|
||||
const TYPE_AUDIO = 'audio';
|
||||
const TYPE_FILE = 'file';
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 관계
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 상담 기록 생성
|
||||
*/
|
||||
public static function createText(int $tenantId, string $scenarioType, ?int $stepId, string $content): self
|
||||
{
|
||||
return self::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'scenario_type' => $scenarioType,
|
||||
'step_id' => $stepId,
|
||||
'consultation_type' => self::TYPE_TEXT,
|
||||
'content' => $content,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 상담 기록 생성
|
||||
*
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @param string $scenarioType 시나리오 타입 (sales/manager)
|
||||
* @param int|null $stepId 단계 ID
|
||||
* @param string $filePath 로컬 파일 경로
|
||||
* @param string $fileName 파일명
|
||||
* @param int $fileSize 파일 크기
|
||||
* @param string|null $transcript 음성 텍스트 변환 결과
|
||||
* @param int|null $duration 녹음 시간 (초)
|
||||
* @param string|null $gcsUri GCS URI (본사 연구용 백업)
|
||||
*/
|
||||
public static function createAudio(
|
||||
int $tenantId,
|
||||
string $scenarioType,
|
||||
?int $stepId,
|
||||
string $filePath,
|
||||
string $fileName,
|
||||
int $fileSize,
|
||||
?string $transcript = null,
|
||||
?int $duration = null,
|
||||
?string $gcsUri = null
|
||||
): self {
|
||||
return self::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'scenario_type' => $scenarioType,
|
||||
'step_id' => $stepId,
|
||||
'consultation_type' => self::TYPE_AUDIO,
|
||||
'file_path' => $filePath,
|
||||
'file_name' => $fileName,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => 'audio/webm',
|
||||
'transcript' => $transcript,
|
||||
'duration' => $duration,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 상담 기록 생성
|
||||
*/
|
||||
public static function createFile(
|
||||
int $tenantId,
|
||||
string $scenarioType,
|
||||
?int $stepId,
|
||||
string $filePath,
|
||||
string $fileName,
|
||||
int $fileSize,
|
||||
string $fileType
|
||||
): self {
|
||||
return self::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'scenario_type' => $scenarioType,
|
||||
'step_id' => $stepId,
|
||||
'consultation_type' => self::TYPE_FILE,
|
||||
'file_path' => $filePath,
|
||||
'file_name' => $fileName,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $fileType,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제 (storage 포함)
|
||||
*/
|
||||
public function deleteWithFile(): bool
|
||||
{
|
||||
if ($this->file_path && Storage::disk('local')->exists($this->file_path)) {
|
||||
Storage::disk('local')->delete($this->file_path);
|
||||
}
|
||||
|
||||
return $this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷된 duration Accessor
|
||||
*/
|
||||
public function getFormattedDurationAttribute(): ?string
|
||||
{
|
||||
if (!$this->duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$minutes = floor($this->duration / 60);
|
||||
$seconds = $this->duration % 60;
|
||||
|
||||
return sprintf('%02d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷된 file size Accessor
|
||||
*/
|
||||
public function getFormattedFileSizeAttribute(): ?string
|
||||
{
|
||||
if (!$this->file_size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->file_size < 1024) {
|
||||
return $this->file_size . ' B';
|
||||
} elseif ($this->file_size < 1024 * 1024) {
|
||||
return round($this->file_size / 1024, 1) . ' KB';
|
||||
} else {
|
||||
return round($this->file_size / (1024 * 1024), 1) . ' MB';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 + 시나리오 타입으로 조회
|
||||
*/
|
||||
public static function getByTenantAndType(int $tenantId, string $scenarioType, ?int $stepId = null)
|
||||
{
|
||||
$query = self::where('tenant_id', $tenantId)
|
||||
->where('scenario_type', $scenarioType)
|
||||
->with('creator')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($stepId !== null) {
|
||||
$query->where('step_id', $stepId);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 타입 스코프
|
||||
*/
|
||||
public function scopeByScenarioType($query, string $type)
|
||||
{
|
||||
return $query->where('scenario_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담 유형 스코프
|
||||
*/
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('consultation_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트만 스코프
|
||||
*/
|
||||
public function scopeTextOnly($query)
|
||||
{
|
||||
return $query->where('consultation_type', self::TYPE_TEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오만 스코프
|
||||
*/
|
||||
public function scopeAudioOnly($query)
|
||||
{
|
||||
return $query->where('consultation_type', self::TYPE_AUDIO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일만 스코프
|
||||
*/
|
||||
public function scopeFileOnly($query)
|
||||
{
|
||||
return $query->where('consultation_type', self::TYPE_FILE);
|
||||
}
|
||||
}
|
||||
106
app/Models/Sales/SalesContractProduct.php
Normal file
106
app/Models/Sales/SalesContractProduct.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 계약별 선택 상품 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $management_id
|
||||
* @property int $category_id
|
||||
* @property int $product_id
|
||||
* @property float|null $registration_fee
|
||||
* @property float|null $subscription_fee
|
||||
* @property float $discount_rate
|
||||
* @property string|null $notes
|
||||
* @property int|null $created_by
|
||||
*/
|
||||
class SalesContractProduct extends Model
|
||||
{
|
||||
protected $table = 'sales_contract_products';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'management_id',
|
||||
'category_id',
|
||||
'product_id',
|
||||
'registration_fee',
|
||||
'subscription_fee',
|
||||
'discount_rate',
|
||||
'notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'integer',
|
||||
'management_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'registration_fee' => 'decimal:2',
|
||||
'subscription_fee' => 'decimal:2',
|
||||
'discount_rate' => 'decimal:2',
|
||||
'created_by' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업관리 관계
|
||||
*/
|
||||
public function management(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTenantManagement::class, 'management_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 관계
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 관계
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesProduct::class, 'product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록자 관계
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 총 가입비
|
||||
*/
|
||||
public static function getTotalRegistrationFee(int $tenantId): float
|
||||
{
|
||||
return self::where('tenant_id', $tenantId)->sum('registration_fee') ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 총 구독료
|
||||
*/
|
||||
public static function getTotalSubscriptionFee(int $tenantId): float
|
||||
{
|
||||
return self::where('tenant_id', $tenantId)->sum('subscription_fee') ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -37,17 +37,15 @@ class SalesManagerDocument extends Model
|
||||
|
||||
/**
|
||||
* 문서 타입 목록
|
||||
* - 계약서는 모두의싸인을 통해 별도 처리
|
||||
*/
|
||||
public const DOCUMENT_TYPES = [
|
||||
'id_card' => '신분증',
|
||||
'business_license' => '사업자등록증',
|
||||
'contract' => '계약서',
|
||||
'resident_copy' => '등본사본',
|
||||
'bank_account' => '통장사본',
|
||||
'other' => '기타',
|
||||
];
|
||||
|
||||
/**
|
||||
* 사용자 (영업담당자)
|
||||
* 사용자 (영업파트너)
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
|
||||
116
app/Models/Sales/SalesPartner.php
Normal file
116
app/Models/Sales/SalesPartner.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 영업 파트너(영업 담당자) 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $partner_code
|
||||
* @property string $partner_type
|
||||
* @property float $commission_rate
|
||||
* @property float $manager_commission_rate
|
||||
* @property string|null $bank_name
|
||||
* @property string|null $account_number
|
||||
* @property string|null $account_holder
|
||||
* @property string $status
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
* @property int|null $approved_by
|
||||
* @property int $total_contracts
|
||||
* @property float $total_commission
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesPartner extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_partners';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'partner_code',
|
||||
'partner_type',
|
||||
'commission_rate',
|
||||
'manager_commission_rate',
|
||||
'bank_name',
|
||||
'account_number',
|
||||
'account_holder',
|
||||
'status',
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'total_contracts',
|
||||
'total_commission',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'commission_rate' => 'decimal:2',
|
||||
'manager_commission_rate' => 'decimal:2',
|
||||
'total_contracts' => 'integer',
|
||||
'total_commission' => 'decimal:2',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 연결된 사용자
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인자
|
||||
*/
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당 테넌트 관리 목록
|
||||
*/
|
||||
public function tenantManagements(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesTenantManagement::class, 'sales_partner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트너 코드 자동 생성
|
||||
*/
|
||||
public static function generatePartnerCode(): string
|
||||
{
|
||||
$prefix = 'SP';
|
||||
$year = now()->format('y');
|
||||
$lastPartner = self::whereYear('created_at', now()->year)
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
$sequence = $lastPartner ? (int) substr($lastPartner->partner_code, -4) + 1 : 1;
|
||||
|
||||
return $prefix . $year . str_pad($sequence, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 파트너 스코프
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 대기 스코프
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending');
|
||||
}
|
||||
}
|
||||
125
app/Models/Sales/SalesProduct.php
Normal file
125
app/Models/Sales/SalesProduct.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 영업 상품 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $category_id
|
||||
* @property string $code
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property float $development_fee
|
||||
* @property float $registration_fee
|
||||
* @property float $subscription_fee
|
||||
* @property float $partner_commission_rate
|
||||
* @property float $manager_commission_rate
|
||||
* @property bool $allow_flexible_pricing
|
||||
* @property bool $is_required
|
||||
* @property int $display_order
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesProduct extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_products';
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'development_fee',
|
||||
'registration_fee',
|
||||
'subscription_fee',
|
||||
'partner_commission_rate',
|
||||
'manager_commission_rate',
|
||||
'allow_flexible_pricing',
|
||||
'is_required',
|
||||
'display_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'category_id' => 'integer',
|
||||
'development_fee' => 'decimal:2',
|
||||
'registration_fee' => 'decimal:2',
|
||||
'subscription_fee' => 'decimal:2',
|
||||
'partner_commission_rate' => 'decimal:2',
|
||||
'manager_commission_rate' => 'decimal:2',
|
||||
'allow_flexible_pricing' => 'boolean',
|
||||
'is_required' => 'boolean',
|
||||
'display_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 카테고리 관계
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 상품 스코프
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 스코프
|
||||
*/
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('display_order')->orderBy('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 수당율
|
||||
*/
|
||||
public function getTotalCommissionRateAttribute(): float
|
||||
{
|
||||
return $this->partner_commission_rate + $this->manager_commission_rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수당 계산 (개발비 기준)
|
||||
*/
|
||||
public function getCommissionAttribute(): float
|
||||
{
|
||||
return $this->development_fee * ($this->total_commission_rate / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷된 개발비
|
||||
*/
|
||||
public function getFormattedDevelopmentFeeAttribute(): string
|
||||
{
|
||||
return '₩' . number_format($this->development_fee);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷된 가입비
|
||||
*/
|
||||
public function getFormattedRegistrationFeeAttribute(): string
|
||||
{
|
||||
return '₩' . number_format($this->registration_fee);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷된 구독료
|
||||
*/
|
||||
public function getFormattedSubscriptionFeeAttribute(): string
|
||||
{
|
||||
return '₩' . number_format($this->subscription_fee);
|
||||
}
|
||||
}
|
||||
71
app/Models/Sales/SalesProductCategory.php
Normal file
71
app/Models/Sales/SalesProductCategory.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 영업 상품 카테고리 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $code
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string $base_storage
|
||||
* @property int $display_order
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesProductCategory extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_product_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'base_storage',
|
||||
'display_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'display_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 상품 관계
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesProduct::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 상품만
|
||||
*/
|
||||
public function activeProducts(): HasMany
|
||||
{
|
||||
return $this->products()->where('is_active', true)->orderBy('display_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 카테고리 스코프
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 스코프
|
||||
*/
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('display_order')->orderBy('name');
|
||||
}
|
||||
}
|
||||
234
app/Models/Sales/SalesScenarioChecklist.php
Normal file
234
app/Models/Sales/SalesScenarioChecklist.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 체크리스트 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $scenario_type (sales/manager)
|
||||
* @property int $step_id
|
||||
* @property string|null $checkpoint_id
|
||||
* @property int|null $checkpoint_index
|
||||
* @property bool $is_checked
|
||||
* @property \Carbon\Carbon|null $checked_at
|
||||
* @property int|null $checked_by
|
||||
* @property string|null $memo
|
||||
*/
|
||||
class SalesScenarioChecklist extends Model
|
||||
{
|
||||
protected $table = 'sales_scenario_checklists';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'scenario_type',
|
||||
'step_id',
|
||||
'checkpoint_id',
|
||||
'checkpoint_index',
|
||||
'is_checked',
|
||||
'checked_at',
|
||||
'checked_by',
|
||||
'memo',
|
||||
'user_id', // 하위 호환성
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'step_id' => 'integer',
|
||||
'checkpoint_index' => 'integer',
|
||||
'is_checked' => 'boolean',
|
||||
'checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크한 사용자 관계
|
||||
*/
|
||||
public function checkedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'checked_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 관계 (하위 호환성)
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크포인트 토글
|
||||
*/
|
||||
public static function toggle(int $tenantId, string $scenarioType, int $stepId, string $checkpointId, bool $checked, ?int $userId = null): self
|
||||
{
|
||||
$currentUserId = $userId ?? auth()->id();
|
||||
|
||||
$checklist = self::firstOrNew([
|
||||
'tenant_id' => $tenantId,
|
||||
'scenario_type' => $scenarioType,
|
||||
'step_id' => $stepId,
|
||||
'checkpoint_id' => $checkpointId,
|
||||
]);
|
||||
|
||||
// 새 레코드인 경우 필수 필드 설정
|
||||
if (!$checklist->exists) {
|
||||
$checklist->user_id = $currentUserId;
|
||||
$checklist->checkpoint_index = 0; // 기본값
|
||||
}
|
||||
|
||||
$checklist->is_checked = $checked;
|
||||
$checklist->checked_at = $checked ? now() : null;
|
||||
$checklist->checked_by = $checked ? $currentUserId : null;
|
||||
$checklist->save();
|
||||
|
||||
return $checklist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테넌트/시나리오의 체크리스트 조회
|
||||
*/
|
||||
public static function getChecklist(int $tenantId, string $scenarioType): array
|
||||
{
|
||||
$items = self::where('tenant_id', $tenantId)
|
||||
->where('scenario_type', $scenarioType)
|
||||
->where('is_checked', true)
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$key = "{$item->step_id}_{$item->checkpoint_id}";
|
||||
$result[$key] = [
|
||||
'checked_at' => $item->checked_at?->toDateTimeString(),
|
||||
'checked_by' => $item->checked_by,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 계산
|
||||
*/
|
||||
public static function calculateProgress(int $tenantId, string $scenarioType, array $steps): array
|
||||
{
|
||||
$checklist = self::getChecklist($tenantId, $scenarioType);
|
||||
|
||||
$totalCheckpoints = 0;
|
||||
$completedCheckpoints = 0;
|
||||
$stepProgress = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
$stepCompleted = 0;
|
||||
$stepTotal = count($step['checkpoints'] ?? []);
|
||||
$totalCheckpoints += $stepTotal;
|
||||
|
||||
foreach ($step['checkpoints'] as $checkpoint) {
|
||||
$key = "{$step['id']}_{$checkpoint['id']}";
|
||||
if (isset($checklist[$key])) {
|
||||
$completedCheckpoints++;
|
||||
$stepCompleted++;
|
||||
}
|
||||
}
|
||||
|
||||
$stepProgress[$step['id']] = [
|
||||
'total' => $stepTotal,
|
||||
'completed' => $stepCompleted,
|
||||
'percentage' => $stepTotal > 0 ? round(($stepCompleted / $stepTotal) * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $totalCheckpoints,
|
||||
'completed' => $completedCheckpoints,
|
||||
'percentage' => $totalCheckpoints > 0 ? round(($completedCheckpoints / $totalCheckpoints) * 100) : 0,
|
||||
'steps' => $stepProgress,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 타입 스코프
|
||||
*/
|
||||
public function scopeByScenarioType($query, string $type)
|
||||
{
|
||||
return $query->where('scenario_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크된 항목만 스코프
|
||||
*/
|
||||
public function scopeChecked($query)
|
||||
{
|
||||
return $query->where('is_checked', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 간단한 진행률 계산 (전체 체크포인트 수 기준)
|
||||
*
|
||||
* @param int $tenantId
|
||||
* @param string $scenarioType 'sales' 또는 'manager'
|
||||
* @return array ['completed' => 완료 수, 'total' => 전체 수, 'percentage' => 백분율]
|
||||
*/
|
||||
public static function getSimpleProgress(int $tenantId, string $scenarioType): array
|
||||
{
|
||||
// 전체 체크포인트 수 (config에서 계산)
|
||||
$configKey = $scenarioType === 'sales' ? 'sales_scenario.sales_steps' : 'sales_scenario.manager_steps';
|
||||
$steps = config($configKey, []);
|
||||
|
||||
$total = 0;
|
||||
$validCheckpointKeys = [];
|
||||
|
||||
// config에 정의된 유효한 체크포인트만 수집
|
||||
foreach ($steps as $step) {
|
||||
foreach ($step['checkpoints'] ?? [] as $checkpoint) {
|
||||
$total++;
|
||||
$validCheckpointKeys[] = "{$step['id']}_{$checkpoint['id']}";
|
||||
}
|
||||
}
|
||||
|
||||
// 완료된 체크포인트 수 (config에 존재하는 것만 카운트)
|
||||
$checkedItems = self::where('tenant_id', $tenantId)
|
||||
->where('scenario_type', $scenarioType)
|
||||
->where('is_checked', true)
|
||||
->get();
|
||||
|
||||
$completed = 0;
|
||||
foreach ($checkedItems as $item) {
|
||||
$key = "{$item->step_id}_{$item->checkpoint_id}";
|
||||
if (in_array($key, $validCheckpointKeys)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
$percentage = $total > 0 ? round(($completed / $total) * 100) : 0;
|
||||
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'total' => $total,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 영업/매니저 진행률 한번에 조회
|
||||
*/
|
||||
public static function getTenantProgress(int $tenantId): array
|
||||
{
|
||||
return [
|
||||
'sales' => self::getSimpleProgress($tenantId, 'sales'),
|
||||
'manager' => self::getSimpleProgress($tenantId, 'manager'),
|
||||
];
|
||||
}
|
||||
}
|
||||
323
app/Models/Sales/SalesTenantManagement.php
Normal file
323
app/Models/Sales/SalesTenantManagement.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sales;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 테넌트별 영업 관리 모델 (tenants 외래키 연결)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int|null $sales_partner_id
|
||||
* @property int|null $manager_user_id
|
||||
* @property int $sales_scenario_step
|
||||
* @property int $manager_scenario_step
|
||||
* @property string $status
|
||||
* @property \Carbon\Carbon|null $first_contact_at
|
||||
* @property \Carbon\Carbon|null $contracted_at
|
||||
* @property \Carbon\Carbon|null $onboarding_completed_at
|
||||
* @property float|null $membership_fee
|
||||
* @property \Carbon\Carbon|null $membership_paid_at
|
||||
* @property string|null $membership_status
|
||||
* @property float|null $sales_commission
|
||||
* @property float|null $manager_commission
|
||||
* @property \Carbon\Carbon|null $commission_paid_at
|
||||
* @property string|null $commission_status
|
||||
* @property int $sales_progress
|
||||
* @property int $manager_progress
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesTenantManagement extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'sales_tenant_managements';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'sales_partner_id',
|
||||
'manager_user_id',
|
||||
'sales_scenario_step',
|
||||
'manager_scenario_step',
|
||||
'status',
|
||||
'first_contact_at',
|
||||
'contracted_at',
|
||||
'onboarding_completed_at',
|
||||
'membership_fee',
|
||||
'membership_paid_at',
|
||||
'membership_status',
|
||||
'sales_commission',
|
||||
'manager_commission',
|
||||
'commission_paid_at',
|
||||
'commission_status',
|
||||
'sales_progress',
|
||||
'manager_progress',
|
||||
'hq_status',
|
||||
'incentive_status',
|
||||
'notes',
|
||||
// 입금 정보
|
||||
'deposit_amount',
|
||||
'deposit_paid_date',
|
||||
'deposit_status',
|
||||
'balance_amount',
|
||||
'balance_paid_date',
|
||||
'balance_status',
|
||||
'total_registration_fee',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sales_scenario_step' => 'integer',
|
||||
'manager_scenario_step' => 'integer',
|
||||
'membership_fee' => 'decimal:2',
|
||||
'sales_commission' => 'decimal:2',
|
||||
'manager_commission' => 'decimal:2',
|
||||
'sales_progress' => 'integer',
|
||||
'manager_progress' => 'integer',
|
||||
'first_contact_at' => 'datetime',
|
||||
'contracted_at' => 'datetime',
|
||||
'onboarding_completed_at' => 'datetime',
|
||||
'membership_paid_at' => 'datetime',
|
||||
'commission_paid_at' => 'datetime',
|
||||
// 입금 정보
|
||||
'deposit_amount' => 'decimal:2',
|
||||
'deposit_paid_date' => 'date',
|
||||
'balance_amount' => 'decimal:2',
|
||||
'balance_paid_date' => 'date',
|
||||
'total_registration_fee' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 상태 상수
|
||||
*/
|
||||
const STATUS_PROSPECT = 'prospect';
|
||||
const STATUS_APPROACH = 'approach';
|
||||
const STATUS_NEGOTIATION = 'negotiation';
|
||||
const STATUS_CONTRACTED = 'contracted';
|
||||
const STATUS_ONBOARDING = 'onboarding';
|
||||
const STATUS_ACTIVE = 'active';
|
||||
const STATUS_CHURNED = 'churned';
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
public static array $statusLabels = [
|
||||
self::STATUS_PROSPECT => '잠재 고객',
|
||||
self::STATUS_APPROACH => '접근 중',
|
||||
self::STATUS_NEGOTIATION => '협상 중',
|
||||
self::STATUS_CONTRACTED => '계약 완료',
|
||||
self::STATUS_ONBOARDING => '온보딩 중',
|
||||
self::STATUS_ACTIVE => '활성 고객',
|
||||
self::STATUS_CHURNED => '이탈',
|
||||
];
|
||||
|
||||
/**
|
||||
* 본사 진행 상태 상수
|
||||
*/
|
||||
const HQ_STATUS_PENDING = 'pending'; // 대기
|
||||
const HQ_STATUS_REVIEW = 'review'; // 검토
|
||||
const HQ_STATUS_PLANNING = 'planning'; // 기획안작성
|
||||
const HQ_STATUS_CODING = 'coding'; // 개발코드작성
|
||||
const HQ_STATUS_DEV_TEST = 'dev_test'; // 개발테스트
|
||||
const HQ_STATUS_DEV_DONE = 'dev_done'; // 개발완료
|
||||
const HQ_STATUS_INT_TEST = 'int_test'; // 통합테스트
|
||||
const HQ_STATUS_HANDOVER = 'handover'; // 인계
|
||||
|
||||
/**
|
||||
* 본사 진행 상태 라벨
|
||||
*/
|
||||
public static array $hqStatusLabels = [
|
||||
self::HQ_STATUS_PENDING => '대기',
|
||||
self::HQ_STATUS_REVIEW => '검토',
|
||||
self::HQ_STATUS_PLANNING => '기획안작성',
|
||||
self::HQ_STATUS_CODING => '개발코드작성',
|
||||
self::HQ_STATUS_DEV_TEST => '개발테스트',
|
||||
self::HQ_STATUS_DEV_DONE => '개발완료',
|
||||
self::HQ_STATUS_INT_TEST => '통합테스트',
|
||||
self::HQ_STATUS_HANDOVER => '인계',
|
||||
];
|
||||
|
||||
/**
|
||||
* 본사 진행 상태 순서 (프로그레스바용)
|
||||
*/
|
||||
public static array $hqStatusOrder = [
|
||||
self::HQ_STATUS_PENDING => 0,
|
||||
self::HQ_STATUS_REVIEW => 1,
|
||||
self::HQ_STATUS_PLANNING => 2,
|
||||
self::HQ_STATUS_CODING => 3,
|
||||
self::HQ_STATUS_DEV_TEST => 4,
|
||||
self::HQ_STATUS_DEV_DONE => 5,
|
||||
self::HQ_STATUS_INT_TEST => 6,
|
||||
self::HQ_STATUS_HANDOVER => 7,
|
||||
];
|
||||
|
||||
/**
|
||||
* 수당 지급 상태 상수
|
||||
*/
|
||||
const INCENTIVE_PENDING = 'pending'; // 대기
|
||||
const INCENTIVE_ELIGIBLE = 'eligible'; // 지급대상
|
||||
const INCENTIVE_PAID = 'paid'; // 지급완료
|
||||
|
||||
/**
|
||||
* 수당 지급 상태 라벨
|
||||
*/
|
||||
public static array $incentiveStatusLabels = [
|
||||
self::INCENTIVE_PENDING => '대기',
|
||||
self::INCENTIVE_ELIGIBLE => '지급대상',
|
||||
self::INCENTIVE_PAID => '지급완료',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업 담당자 관계
|
||||
*/
|
||||
public function salesPartner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesPartner::class, 'sales_partner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 매니저 관계
|
||||
*/
|
||||
public function manager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'manager_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 관계
|
||||
*/
|
||||
public function checklists(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesScenarioChecklist::class, 'tenant_id', 'tenant_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담 기록 관계
|
||||
*/
|
||||
public function consultations(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesConsultation::class, 'tenant_id', 'tenant_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수수료 정산 관계
|
||||
*/
|
||||
public function commissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesCommission::class, 'management_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 상품 관계
|
||||
*/
|
||||
public function contractProducts(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesContractProduct::class, 'management_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 ID로 조회 또는 생성
|
||||
*/
|
||||
public static function findOrCreateByTenant(int $tenantId): self
|
||||
{
|
||||
return self::firstOrCreate(
|
||||
['tenant_id' => $tenantId],
|
||||
[
|
||||
'status' => self::STATUS_PROSPECT,
|
||||
'sales_scenario_step' => 1,
|
||||
'manager_scenario_step' => 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 업데이트
|
||||
*/
|
||||
public function updateProgress(string $scenarioType, int $progress): void
|
||||
{
|
||||
$field = $scenarioType === 'sales' ? 'sales_progress' : 'manager_progress';
|
||||
$this->update([$field => $progress]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 단계 업데이트
|
||||
*/
|
||||
public function updateStep(string $scenarioType, int $step): void
|
||||
{
|
||||
$field = $scenarioType === 'sales' ? 'sales_scenario_step' : 'manager_scenario_step';
|
||||
$this->update([$field => $step]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨 Accessor
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::$statusLabels[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본사 진행 상태 라벨 Accessor
|
||||
*/
|
||||
public function getHqStatusLabelAttribute(): string
|
||||
{
|
||||
return self::$hqStatusLabels[$this->hq_status ?? self::HQ_STATUS_PENDING] ?? '대기';
|
||||
}
|
||||
|
||||
/**
|
||||
* 본사 진행 상태 순서 (0-7)
|
||||
*/
|
||||
public function getHqStatusStepAttribute(): int
|
||||
{
|
||||
return self::$hqStatusOrder[$this->hq_status ?? self::HQ_STATUS_PENDING] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수당 지급 상태 라벨 Accessor
|
||||
*/
|
||||
public function getIncentiveStatusLabelAttribute(): string
|
||||
{
|
||||
return self::$incentiveStatusLabels[$this->incentive_status ?? self::INCENTIVE_PENDING] ?? '대기';
|
||||
}
|
||||
|
||||
/**
|
||||
* 본사 진행 가능 여부 (매니저 100% 완료 시)
|
||||
*/
|
||||
public function isHqProgressEnabled(): bool
|
||||
{
|
||||
return $this->manager_progress >= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 상태 스코프
|
||||
*/
|
||||
public function scopeByStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 완료 스코프
|
||||
*/
|
||||
public function scopeContracted($query)
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
self::STATUS_CONTRACTED,
|
||||
self::STATUS_ONBOARDING,
|
||||
self::STATUS_ACTIVE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class TenantProspect extends Model
|
||||
public const STATUS_CONVERTED = 'converted'; // 테넌트 전환 완료
|
||||
|
||||
public const VALIDITY_MONTHS = 2; // 영업권 유효기간 (개월)
|
||||
public const COOLDOWN_MONTHS = 1; // 쿨다운 기간 (개월)
|
||||
public const COOLDOWN_MONTHS = 1; // 재등록 대기 기간 (개월)
|
||||
|
||||
protected $fillable = [
|
||||
'business_number',
|
||||
@@ -102,7 +102,7 @@ public function isConverted(): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿨다운 중 여부
|
||||
* 재등록 대기 중 여부
|
||||
*/
|
||||
public function isInCooldown(): bool
|
||||
{
|
||||
@@ -131,7 +131,7 @@ public function getStatusLabelAttribute(): string
|
||||
}
|
||||
|
||||
if ($this->isInCooldown()) {
|
||||
return '쿨다운';
|
||||
return '대기중';
|
||||
}
|
||||
|
||||
return '만료';
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 체크리스트 모델
|
||||
*/
|
||||
class SalesScenarioChecklist extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'step_id',
|
||||
'checkpoint_index',
|
||||
'is_checked',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'step_id' => 'integer',
|
||||
'checkpoint_index' => 'integer',
|
||||
'is_checked' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 사용자 관계
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class AiConfig extends Model
|
||||
'gemini' => 'https://generativelanguage.googleapis.com/v1beta',
|
||||
'claude' => 'https://api.anthropic.com/v1',
|
||||
'openai' => 'https://api.openai.com/v1',
|
||||
'gcs' => 'https://storage.googleapis.com',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -60,8 +61,19 @@ class AiConfig extends Model
|
||||
'gemini' => 'gemini-2.0-flash',
|
||||
'claude' => 'claude-sonnet-4-20250514',
|
||||
'openai' => 'gpt-4o',
|
||||
'gcs' => '-',
|
||||
];
|
||||
|
||||
/**
|
||||
* AI Provider 목록 (GCS 제외)
|
||||
*/
|
||||
public const AI_PROVIDERS = ['gemini', 'claude', 'openai'];
|
||||
|
||||
/**
|
||||
* 스토리지 Provider 목록
|
||||
*/
|
||||
public const STORAGE_PROVIDERS = ['gcs'];
|
||||
|
||||
/**
|
||||
* 활성화된 Gemini 설정 조회
|
||||
*/
|
||||
@@ -109,10 +121,53 @@ public function getProviderLabelAttribute(): string
|
||||
'gemini' => 'Google Gemini',
|
||||
'claude' => 'Anthropic Claude',
|
||||
'openai' => 'OpenAI',
|
||||
'gcs' => 'Google Cloud Storage',
|
||||
default => $this->provider,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 GCS 설정 조회
|
||||
*/
|
||||
public static function getActiveGcs(): ?self
|
||||
{
|
||||
return self::where('provider', 'gcs')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 버킷 이름
|
||||
*/
|
||||
public function getBucketName(): ?string
|
||||
{
|
||||
return $this->options['bucket_name'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 서비스 계정 JSON (직접 저장된 경우)
|
||||
*/
|
||||
public function getServiceAccountJson(): ?array
|
||||
{
|
||||
return $this->options['service_account_json'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 설정인지 확인
|
||||
*/
|
||||
public function isGcs(): bool
|
||||
{
|
||||
return $this->provider === 'gcs';
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 설정인지 확인
|
||||
*/
|
||||
public function isAi(): bool
|
||||
{
|
||||
return in_array($this->provider, self::AI_PROVIDERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
|
||||
@@ -72,7 +72,7 @@ protected function casts(): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 상위 관리자 (영업담당자 계층 구조)
|
||||
* 상위 관리자 (영업파트너 계층 구조)
|
||||
*/
|
||||
public function parent(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
@@ -96,7 +96,7 @@ public function approver(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업담당자 첨부 서류
|
||||
* 영업파트너 첨부 서류
|
||||
*/
|
||||
public function salesDocuments(): HasMany
|
||||
{
|
||||
|
||||
307
app/Services/GoogleCloudStorageService.php
Normal file
307
app/Services/GoogleCloudStorageService.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\System\AiConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Google Cloud Storage 업로드 서비스
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. DB 설정 (ui에서 오버라이드)
|
||||
* 2. 환경변수 (.env)
|
||||
* 3. 레거시 파일 (/sales/apikey/)
|
||||
*
|
||||
* JWT 인증 방식 사용.
|
||||
*/
|
||||
class GoogleCloudStorageService
|
||||
{
|
||||
private ?string $bucketName = null;
|
||||
private ?array $serviceAccount = null;
|
||||
private string $configSource = 'none';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 설정 로드
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider)
|
||||
* 2. 환경변수 (.env의 GCS_BUCKET_NAME, GCS_SERVICE_ACCOUNT_PATH)
|
||||
* 3. 레거시 파일 설정 (/sales/apikey/)
|
||||
*/
|
||||
private function loadConfig(): void
|
||||
{
|
||||
// 1. DB 설정 확인 (GCS_USE_DB_CONFIG=true일 때만)
|
||||
if (config('gcs.use_db_config', true)) {
|
||||
$dbConfig = AiConfig::getActiveGcs();
|
||||
|
||||
if ($dbConfig) {
|
||||
$this->bucketName = $dbConfig->getBucketName();
|
||||
|
||||
// 서비스 계정: JSON 직접 입력 또는 파일 경로
|
||||
if ($dbConfig->getServiceAccountJson()) {
|
||||
$this->serviceAccount = $dbConfig->getServiceAccountJson();
|
||||
} elseif ($dbConfig->getServiceAccountPath() && file_exists($dbConfig->getServiceAccountPath())) {
|
||||
$this->serviceAccount = json_decode(file_get_contents($dbConfig->getServiceAccountPath()), true);
|
||||
}
|
||||
|
||||
if ($this->bucketName && $this->serviceAccount) {
|
||||
$this->configSource = 'db';
|
||||
Log::debug('GCS 설정 로드: DB (활성화된 설정: ' . $dbConfig->name . ')');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 환경변수 (.env) 설정
|
||||
$envBucket = config('gcs.bucket_name');
|
||||
$envServiceAccountPath = config('gcs.service_account_path');
|
||||
|
||||
if ($envBucket && $envServiceAccountPath && file_exists($envServiceAccountPath)) {
|
||||
$this->bucketName = $envBucket;
|
||||
$this->serviceAccount = json_decode(file_get_contents($envServiceAccountPath), true);
|
||||
|
||||
if ($this->serviceAccount) {
|
||||
$this->configSource = 'env';
|
||||
Log::debug('GCS 설정 로드: 환경변수 (.env)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 레거시 파일 설정 (fallback)
|
||||
$gcsConfigPath = base_path('../sales/apikey/gcs_config.txt');
|
||||
if (file_exists($gcsConfigPath)) {
|
||||
$config = parse_ini_file($gcsConfigPath);
|
||||
$this->bucketName = $config['bucket_name'] ?? null;
|
||||
}
|
||||
|
||||
$serviceAccountPath = base_path('../sales/apikey/google_service_account.json');
|
||||
if (file_exists($serviceAccountPath)) {
|
||||
$this->serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
}
|
||||
|
||||
if ($this->bucketName && $this->serviceAccount) {
|
||||
$this->configSource = 'legacy';
|
||||
Log::debug('GCS 설정 로드: 레거시 파일');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 설정 소스 반환
|
||||
*/
|
||||
public function getConfigSource(): string
|
||||
{
|
||||
return $this->configSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS가 사용 가능한지 확인
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->bucketName !== null && $this->serviceAccount !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS에 파일 업로드
|
||||
*
|
||||
* @param string $filePath 로컬 파일 경로
|
||||
* @param string $objectName GCS에 저장할 객체 이름
|
||||
* @return string|null GCS URI (gs://bucket/object) 또는 실패 시 null
|
||||
*/
|
||||
public function upload(string $filePath, string $objectName): ?string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
Log::warning('GCS 업로드 실패: 설정되지 않음');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
Log::error('GCS 업로드 실패: 파일 없음 - ' . $filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// OAuth 2.0 토큰 생성
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// GCS에 파일 업로드
|
||||
$fileContent = file_get_contents($filePath);
|
||||
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
|
||||
|
||||
$uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/' .
|
||||
urlencode($this->bucketName) . '/o?uploadType=media&name=' .
|
||||
urlencode($objectName);
|
||||
|
||||
$ch = curl_init($uploadUrl);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
'Content-Type: ' . $mimeType,
|
||||
'Content-Length: ' . strlen($fileContent)
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5분 타임아웃
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$gcsUri = 'gs://' . $this->bucketName . '/' . $objectName;
|
||||
Log::info('GCS 업로드 성공: ' . $gcsUri);
|
||||
return $gcsUri;
|
||||
}
|
||||
|
||||
Log::error('GCS 업로드 실패 (HTTP ' . $httpCode . '): ' . ($error ?: $response));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS에서 서명된 다운로드 URL 생성
|
||||
*
|
||||
* @param string $objectName GCS 객체 이름
|
||||
* @param int $expiresInMinutes URL 유효 시간 (분)
|
||||
* @return string|null 서명된 URL 또는 실패 시 null
|
||||
*/
|
||||
public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiration = time() + ($expiresInMinutes * 60);
|
||||
$stringToSign = "GET\n\n\n{$expiration}\n/{$this->bucketName}/{$objectName}";
|
||||
|
||||
$privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
Log::error('GCS URL 서명 실패: 개인 키 읽기 오류');
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$encodedSignature = urlencode(base64_encode($signature));
|
||||
$clientEmail = urlencode($this->serviceAccount['client_email']);
|
||||
|
||||
return "https://storage.googleapis.com/{$this->bucketName}/{$objectName}" .
|
||||
"?GoogleAccessId={$clientEmail}" .
|
||||
"&Expires={$expiration}" .
|
||||
"&Signature={$encodedSignature}";
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS에서 파일 삭제
|
||||
*
|
||||
* @param string $objectName GCS 객체 이름
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
public function delete(string $objectName): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deleteUrl = 'https://storage.googleapis.com/storage/v1/b/' .
|
||||
urlencode($this->bucketName) . '/o/' .
|
||||
urlencode($objectName);
|
||||
|
||||
$ch = curl_init($deleteUrl);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 204 || $httpCode === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.0 액세스 토큰 획득
|
||||
*/
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// JWT 생성
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $this->serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
Log::error('GCS 토큰 실패: 개인 키 읽기 오류');
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
// OAuth 토큰 요청
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
Log::error('GCS 토큰 실패: HTTP ' . $httpCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
return $data['access_token'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL 인코딩
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* 버킷 이름 반환
|
||||
*/
|
||||
public function getBucketName(): ?string
|
||||
{
|
||||
return $this->bucketName;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\DepartmentUser;
|
||||
use App\Models\Role;
|
||||
use App\Models\Sales\SalesManagerDocument;
|
||||
@@ -53,7 +54,10 @@ public function createSalesPartner(array $data, array $documents = []): User
|
||||
$this->syncRoles($user, $tenantId, $data['role_ids']);
|
||||
}
|
||||
|
||||
// 4. 첨부 서류 저장
|
||||
// 4. 영업팀 부서 자동 할당
|
||||
$this->assignSalesDepartment($user, $tenantId);
|
||||
|
||||
// 5. 첨부 서류 저장
|
||||
if (!empty($documents)) {
|
||||
$this->uploadDocuments($user, $tenantId, $documents);
|
||||
}
|
||||
@@ -229,6 +233,41 @@ public function removeRole(User $user, string $roleName): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업팀 부서 자동 할당
|
||||
*/
|
||||
private function assignSalesDepartment(User $user, int $tenantId): void
|
||||
{
|
||||
// "영업팀" 부서를 찾거나 생성
|
||||
$salesDepartment = Department::firstOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => '영업팀',
|
||||
],
|
||||
[
|
||||
'code' => 'SALES',
|
||||
'description' => '영업파트너 부서',
|
||||
'is_active' => true,
|
||||
'sort_order' => 100,
|
||||
'created_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
// 사용자-부서 연결 (이미 있으면 무시)
|
||||
DepartmentUser::firstOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'department_id' => $salesDepartment->id,
|
||||
'user_id' => $user->id,
|
||||
],
|
||||
[
|
||||
'is_primary' => true,
|
||||
'joined_at' => now(),
|
||||
'created_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 동기화
|
||||
*/
|
||||
|
||||
@@ -14,17 +14,19 @@ class TenantProspectService
|
||||
/**
|
||||
* 명함 등록 (영업권 확보)
|
||||
*/
|
||||
public function register(array $data, ?UploadedFile $businessCard = null): TenantProspect
|
||||
public function register(array $data, ?UploadedFile $businessCard = null, ?string $businessCardBase64 = null): TenantProspect
|
||||
{
|
||||
return DB::transaction(function () use ($data, $businessCard) {
|
||||
return DB::transaction(function () use ($data, $businessCard, $businessCardBase64) {
|
||||
$now = now();
|
||||
$expiresAt = $now->copy()->addMonths(TenantProspect::VALIDITY_MONTHS);
|
||||
$cooldownEndsAt = $expiresAt->copy()->addMonths(TenantProspect::COOLDOWN_MONTHS);
|
||||
|
||||
// 명함 이미지 저장
|
||||
// 명함 이미지 저장 (파일 업로드 또는 Base64)
|
||||
$businessCardPath = null;
|
||||
if ($businessCard) {
|
||||
$businessCardPath = $this->uploadBusinessCard($businessCard, $data['registered_by']);
|
||||
} elseif ($businessCardBase64) {
|
||||
$businessCardPath = $this->saveBase64Image($businessCardBase64, $data['registered_by']);
|
||||
}
|
||||
|
||||
return TenantProspect::create([
|
||||
@@ -101,17 +103,27 @@ public function update(
|
||||
public function convertToTenant(TenantProspect $prospect, int $convertedBy): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($prospect, $convertedBy) {
|
||||
// 고유 테넌트 코드 생성 (T + 타임스탬프 + 랜덤)
|
||||
$tenantCode = 'T' . now()->format('ymd') . strtoupper(substr(uniqid(), -4));
|
||||
|
||||
// 테넌트 생성
|
||||
$tenant = Tenant::create([
|
||||
'company_name' => $prospect->company_name,
|
||||
'code' => $tenantCode,
|
||||
'business_num' => $prospect->business_number,
|
||||
'ceo_name' => $prospect->ceo_name,
|
||||
'phone' => $prospect->contact_phone,
|
||||
'email' => $prospect->contact_email,
|
||||
'address' => $prospect->address,
|
||||
'tenant_st_code' => 'trial',
|
||||
'tenant_type' => 'customer',
|
||||
'created_by' => $convertedBy,
|
||||
'tenant_type' => 'STD', // STD = Standard (일반 고객)
|
||||
]);
|
||||
|
||||
// 전환한 사용자를 테넌트에 연결 (user_tenants)
|
||||
$tenant->users()->attach($convertedBy, [
|
||||
'is_active' => true,
|
||||
'is_default' => false,
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
// 영업권 상태 업데이트
|
||||
@@ -167,7 +179,7 @@ public function canRegister(string $businessNumber, ?int $excludeId = null): arr
|
||||
];
|
||||
}
|
||||
|
||||
// 쿨다운 중인 경우
|
||||
// 재등록 대기 중인 경우
|
||||
$inCooldown = (clone $query)
|
||||
->where('status', TenantProspect::STATUS_EXPIRED)
|
||||
->where('cooldown_ends_at', '>', now())
|
||||
@@ -176,7 +188,7 @@ public function canRegister(string $businessNumber, ?int $excludeId = null): arr
|
||||
if ($inCooldown) {
|
||||
return [
|
||||
'can_register' => false,
|
||||
'reason' => "쿨다운 기간 중입니다. (등록 가능: {$inCooldown->cooldown_ends_at->format('Y-m-d')})",
|
||||
'reason' => "재등록 대기 기간 중입니다. (등록 가능: {$inCooldown->cooldown_ends_at->format('Y-m-d')})",
|
||||
'prospect' => $inCooldown,
|
||||
];
|
||||
}
|
||||
@@ -265,6 +277,32 @@ private function uploadBusinessCard(UploadedFile $file, int $userId): string
|
||||
return $this->uploadAttachment($file, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 이미지 저장
|
||||
*/
|
||||
private function saveBase64Image(string $base64Data, int $userId): ?string
|
||||
{
|
||||
// data:image/jpeg;base64,... 형식에서 데이터 추출
|
||||
if (preg_match('/^data:image\/(\w+);base64,/', $base64Data, $matches)) {
|
||||
$extension = $matches[1];
|
||||
$base64Data = preg_replace('/^data:image\/\w+;base64,/', '', $base64Data);
|
||||
} else {
|
||||
$extension = 'jpg';
|
||||
}
|
||||
|
||||
$imageData = base64_decode($base64Data);
|
||||
if ($imageData === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$storedName = Str::uuid() . '.' . $extension;
|
||||
$filePath = "prospects/{$userId}/{$storedName}";
|
||||
|
||||
Storage::disk('tenant')->put($filePath, $imageData);
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 명함 이미지 삭제
|
||||
*/
|
||||
|
||||
449
app/Services/SalesCommissionService.php
Normal file
449
app/Services/SalesCommissionService.php
Normal file
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Sales\SalesCommission;
|
||||
use App\Models\Sales\SalesCommissionDetail;
|
||||
use App\Models\Sales\SalesContractProduct;
|
||||
use App\Models\Sales\SalesPartner;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SalesCommissionService
|
||||
{
|
||||
/**
|
||||
* 기본 수당률
|
||||
*/
|
||||
const DEFAULT_PARTNER_RATE = 20.00;
|
||||
const DEFAULT_MANAGER_RATE = 5.00;
|
||||
|
||||
// =========================================================================
|
||||
// 정산 목록 조회
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 정산 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getCommissions(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$query = SalesCommission::query()
|
||||
->with(['tenant', 'partner.user', 'manager', 'management']);
|
||||
|
||||
// 상태 필터
|
||||
if (!empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
// 입금구분 필터
|
||||
if (!empty($filters['payment_type'])) {
|
||||
$query->where('payment_type', $filters['payment_type']);
|
||||
}
|
||||
|
||||
// 영업파트너 필터
|
||||
if (!empty($filters['partner_id'])) {
|
||||
$query->where('partner_id', $filters['partner_id']);
|
||||
}
|
||||
|
||||
// 매니저 필터
|
||||
if (!empty($filters['manager_user_id'])) {
|
||||
$query->where('manager_user_id', $filters['manager_user_id']);
|
||||
}
|
||||
|
||||
// 지급예정 년/월 필터
|
||||
if (!empty($filters['scheduled_year']) && !empty($filters['scheduled_month'])) {
|
||||
$query->forScheduledMonth((int) $filters['scheduled_year'], (int) $filters['scheduled_month']);
|
||||
}
|
||||
|
||||
// 입금일 기간 필터
|
||||
if (!empty($filters['payment_start_date']) && !empty($filters['payment_end_date'])) {
|
||||
$query->paymentDateBetween($filters['payment_start_date'], $filters['payment_end_date']);
|
||||
}
|
||||
|
||||
// 테넌트 검색
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->whereHas('tenant', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('company_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('scheduled_payment_date', 'desc')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 상세 조회
|
||||
*/
|
||||
public function getCommissionById(int $id): ?SalesCommission
|
||||
{
|
||||
return SalesCommission::with([
|
||||
'tenant',
|
||||
'partner.user',
|
||||
'manager',
|
||||
'management',
|
||||
'details.contractProduct.product',
|
||||
'approver',
|
||||
])->find($id);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 수당 생성 (입금 시)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 입금 등록 및 수당 생성
|
||||
*/
|
||||
public function createCommission(int $managementId, string $paymentType, float $paymentAmount, string $paymentDate): SalesCommission
|
||||
{
|
||||
return DB::transaction(function () use ($managementId, $paymentType, $paymentAmount, $paymentDate) {
|
||||
$management = SalesTenantManagement::with(['salesPartner', 'contractProducts.product'])
|
||||
->findOrFail($managementId);
|
||||
|
||||
// 영업파트너 필수 체크
|
||||
if (!$management->sales_partner_id) {
|
||||
throw new \Exception('영업파트너가 지정되지 않았습니다.');
|
||||
}
|
||||
|
||||
$partner = $management->salesPartner;
|
||||
$paymentDateCarbon = Carbon::parse($paymentDate);
|
||||
|
||||
// 계약 상품이 없으면 기본 계산
|
||||
$contractProducts = $management->contractProducts;
|
||||
$totalRegistrationFee = $contractProducts->sum('registration_fee') ?: $paymentAmount * 2;
|
||||
$baseAmount = $totalRegistrationFee / 2; // 가입비의 50%
|
||||
|
||||
// 수당률 (영업파트너 설정 또는 기본값)
|
||||
$partnerRate = $partner->commission_rate ?? self::DEFAULT_PARTNER_RATE;
|
||||
$managerRate = $partner->manager_commission_rate ?? self::DEFAULT_MANAGER_RATE;
|
||||
|
||||
// 수당 계산
|
||||
$partnerCommission = $baseAmount * ($partnerRate / 100);
|
||||
$managerCommission = $management->manager_user_id
|
||||
? $baseAmount * ($managerRate / 100)
|
||||
: 0;
|
||||
|
||||
// 지급예정일 (익월 10일)
|
||||
$scheduledPaymentDate = SalesCommission::calculateScheduledPaymentDate($paymentDateCarbon);
|
||||
|
||||
// 정산 생성
|
||||
$commission = SalesCommission::create([
|
||||
'tenant_id' => $management->tenant_id,
|
||||
'management_id' => $managementId,
|
||||
'payment_type' => $paymentType,
|
||||
'payment_amount' => $paymentAmount,
|
||||
'payment_date' => $paymentDate,
|
||||
'base_amount' => $baseAmount,
|
||||
'partner_rate' => $partnerRate,
|
||||
'manager_rate' => $managerRate,
|
||||
'partner_commission' => $partnerCommission,
|
||||
'manager_commission' => $managerCommission,
|
||||
'scheduled_payment_date' => $scheduledPaymentDate,
|
||||
'status' => SalesCommission::STATUS_PENDING,
|
||||
'partner_id' => $partner->id,
|
||||
'manager_user_id' => $management->manager_user_id,
|
||||
]);
|
||||
|
||||
// 상품별 상세 내역 생성
|
||||
foreach ($contractProducts as $contractProduct) {
|
||||
$productBaseAmount = ($contractProduct->registration_fee ?? 0) / 2;
|
||||
$productPartnerRate = $contractProduct->product->partner_commission ?? $partnerRate;
|
||||
$productManagerRate = $contractProduct->product->manager_commission ?? $managerRate;
|
||||
|
||||
SalesCommissionDetail::create([
|
||||
'commission_id' => $commission->id,
|
||||
'contract_product_id' => $contractProduct->id,
|
||||
'registration_fee' => $contractProduct->registration_fee ?? 0,
|
||||
'base_amount' => $productBaseAmount,
|
||||
'partner_rate' => $productPartnerRate,
|
||||
'manager_rate' => $productManagerRate,
|
||||
'partner_commission' => $productBaseAmount * ($productPartnerRate / 100),
|
||||
'manager_commission' => $productBaseAmount * ($productManagerRate / 100),
|
||||
]);
|
||||
}
|
||||
|
||||
// management 입금 정보 업데이트
|
||||
$updateData = [];
|
||||
if ($paymentType === SalesCommission::PAYMENT_DEPOSIT) {
|
||||
$updateData = [
|
||||
'deposit_amount' => $paymentAmount,
|
||||
'deposit_paid_date' => $paymentDate,
|
||||
'deposit_status' => 'paid',
|
||||
];
|
||||
} else {
|
||||
$updateData = [
|
||||
'balance_amount' => $paymentAmount,
|
||||
'balance_paid_date' => $paymentDate,
|
||||
'balance_status' => 'paid',
|
||||
];
|
||||
}
|
||||
|
||||
// 총 가입비 업데이트
|
||||
$updateData['total_registration_fee'] = $totalRegistrationFee;
|
||||
$management->update($updateData);
|
||||
|
||||
return $commission->load(['tenant', 'partner.user', 'manager', 'details']);
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 승인/지급 처리
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
public function approve(int $commissionId, int $approverId): SalesCommission
|
||||
{
|
||||
$commission = SalesCommission::findOrFail($commissionId);
|
||||
|
||||
if (!$commission->approve($approverId)) {
|
||||
throw new \Exception('승인할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
return $commission->fresh(['tenant', 'partner.user', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 승인
|
||||
*/
|
||||
public function bulkApprove(array $ids, int $approverId): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
DB::transaction(function () use ($ids, $approverId, &$count) {
|
||||
$commissions = SalesCommission::whereIn('id', $ids)
|
||||
->where('status', SalesCommission::STATUS_PENDING)
|
||||
->get();
|
||||
|
||||
foreach ($commissions as $commission) {
|
||||
if ($commission->approve($approverId)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급완료 처리
|
||||
*/
|
||||
public function markAsPaid(int $commissionId, ?string $bankReference = null): SalesCommission
|
||||
{
|
||||
$commission = SalesCommission::findOrFail($commissionId);
|
||||
|
||||
if (!$commission->markAsPaid($bankReference)) {
|
||||
throw new \Exception('지급완료 처리할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
// 영업파트너 누적 수당 업데이트
|
||||
$this->updatePartnerTotalCommission($commission->partner_id);
|
||||
|
||||
return $commission->fresh(['tenant', 'partner.user', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 지급완료
|
||||
*/
|
||||
public function bulkMarkAsPaid(array $ids, ?string $bankReference = null): int
|
||||
{
|
||||
$count = 0;
|
||||
$partnerIds = [];
|
||||
|
||||
DB::transaction(function () use ($ids, $bankReference, &$count, &$partnerIds) {
|
||||
$commissions = SalesCommission::whereIn('id', $ids)
|
||||
->where('status', SalesCommission::STATUS_APPROVED)
|
||||
->get();
|
||||
|
||||
foreach ($commissions as $commission) {
|
||||
if ($commission->markAsPaid($bankReference)) {
|
||||
$count++;
|
||||
$partnerIds[] = $commission->partner_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 영업파트너 누적 수당 일괄 업데이트
|
||||
foreach (array_unique($partnerIds) as $partnerId) {
|
||||
$this->updatePartnerTotalCommission($partnerId);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 처리
|
||||
*/
|
||||
public function cancel(int $commissionId): SalesCommission
|
||||
{
|
||||
$commission = SalesCommission::findOrFail($commissionId);
|
||||
|
||||
if (!$commission->cancel()) {
|
||||
throw new \Exception('취소할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
return $commission->fresh(['tenant', 'partner.user', 'manager']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 영업파트너/매니저 대시보드용
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 영업파트너 수당 요약
|
||||
*/
|
||||
public function getPartnerCommissionSummary(int $partnerId): array
|
||||
{
|
||||
$commissions = SalesCommission::forPartner($partnerId)->get();
|
||||
|
||||
$thisMonth = now()->format('Y-m');
|
||||
$thisMonthStart = now()->startOfMonth()->format('Y-m-d');
|
||||
$thisMonthEnd = now()->endOfMonth()->format('Y-m-d');
|
||||
|
||||
return [
|
||||
// 이번 달 지급예정 (승인 완료된 건)
|
||||
'scheduled_this_month' => $commissions
|
||||
->where('status', SalesCommission::STATUS_APPROVED)
|
||||
->filter(fn($c) => $c->scheduled_payment_date->format('Y-m') === $thisMonth)
|
||||
->sum('partner_commission'),
|
||||
|
||||
// 누적 수령 수당
|
||||
'total_received' => $commissions
|
||||
->where('status', SalesCommission::STATUS_PAID)
|
||||
->sum('partner_commission'),
|
||||
|
||||
// 대기중 수당
|
||||
'pending_amount' => $commissions
|
||||
->where('status', SalesCommission::STATUS_PENDING)
|
||||
->sum('partner_commission'),
|
||||
|
||||
// 이번 달 신규 계약 건수
|
||||
'contracts_this_month' => $commissions
|
||||
->filter(fn($c) => $c->payment_date >= $thisMonthStart && $c->payment_date <= $thisMonthEnd)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 수당 요약
|
||||
*/
|
||||
public function getManagerCommissionSummary(int $managerUserId): array
|
||||
{
|
||||
$commissions = SalesCommission::forManager($managerUserId)->get();
|
||||
|
||||
$thisMonth = now()->format('Y-m');
|
||||
|
||||
return [
|
||||
// 이번 달 지급예정 (승인 완료된 건)
|
||||
'scheduled_this_month' => $commissions
|
||||
->where('status', SalesCommission::STATUS_APPROVED)
|
||||
->filter(fn($c) => $c->scheduled_payment_date->format('Y-m') === $thisMonth)
|
||||
->sum('manager_commission'),
|
||||
|
||||
// 누적 수령 수당
|
||||
'total_received' => $commissions
|
||||
->where('status', SalesCommission::STATUS_PAID)
|
||||
->sum('manager_commission'),
|
||||
|
||||
// 대기중 수당
|
||||
'pending_amount' => $commissions
|
||||
->where('status', SalesCommission::STATUS_PENDING)
|
||||
->sum('manager_commission'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 수당 내역 (대시보드용)
|
||||
*/
|
||||
public function getRecentCommissions(int $partnerId, int $limit = 5): Collection
|
||||
{
|
||||
return SalesCommission::forPartner($partnerId)
|
||||
->with(['tenant', 'management'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 통계
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 정산 통계 (본사 대시보드용)
|
||||
*/
|
||||
public function getSettlementStats(int $year, int $month): array
|
||||
{
|
||||
$commissions = SalesCommission::forScheduledMonth($year, $month)->get();
|
||||
|
||||
return [
|
||||
// 상태별 건수 및 금액
|
||||
'pending' => [
|
||||
'count' => $commissions->where('status', SalesCommission::STATUS_PENDING)->count(),
|
||||
'partner_total' => $commissions->where('status', SalesCommission::STATUS_PENDING)->sum('partner_commission'),
|
||||
'manager_total' => $commissions->where('status', SalesCommission::STATUS_PENDING)->sum('manager_commission'),
|
||||
],
|
||||
'approved' => [
|
||||
'count' => $commissions->where('status', SalesCommission::STATUS_APPROVED)->count(),
|
||||
'partner_total' => $commissions->where('status', SalesCommission::STATUS_APPROVED)->sum('partner_commission'),
|
||||
'manager_total' => $commissions->where('status', SalesCommission::STATUS_APPROVED)->sum('manager_commission'),
|
||||
],
|
||||
'paid' => [
|
||||
'count' => $commissions->where('status', SalesCommission::STATUS_PAID)->count(),
|
||||
'partner_total' => $commissions->where('status', SalesCommission::STATUS_PAID)->sum('partner_commission'),
|
||||
'manager_total' => $commissions->where('status', SalesCommission::STATUS_PAID)->sum('manager_commission'),
|
||||
],
|
||||
|
||||
// 전체 합계
|
||||
'total' => [
|
||||
'count' => $commissions->count(),
|
||||
'base_amount' => $commissions->sum('base_amount'),
|
||||
'partner_commission' => $commissions->sum('partner_commission'),
|
||||
'manager_commission' => $commissions->sum('manager_commission'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 대기 중인 테넌트 목록
|
||||
*/
|
||||
public function getPendingPaymentTenants(): Collection
|
||||
{
|
||||
return SalesTenantManagement::with(['tenant', 'salesPartner.user', 'manager'])
|
||||
->contracted()
|
||||
->where(function ($query) {
|
||||
$query->where('deposit_status', 'pending')
|
||||
->orWhere('balance_status', 'pending');
|
||||
})
|
||||
->orderBy('contracted_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 메서드
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 영업파트너 누적 수당 업데이트
|
||||
*/
|
||||
private function updatePartnerTotalCommission(int $partnerId): void
|
||||
{
|
||||
$totalPaid = SalesCommission::forPartner($partnerId)
|
||||
->paid()
|
||||
->sum('partner_commission');
|
||||
|
||||
$contractCount = SalesCommission::forPartner($partnerId)
|
||||
->paid()
|
||||
->count();
|
||||
|
||||
SalesPartner::where('id', $partnerId)->update([
|
||||
'total_commission' => $totalPaid,
|
||||
'total_contracts' => $contractCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,21 @@ public function forceDeleteUser(int $id): bool
|
||||
// 2. 관련 데이터 삭제
|
||||
$user->tenants()->detach(); // user_tenants 관계 삭제
|
||||
|
||||
// 2-1. user_roles 영구 삭제 (외래 키 제약 때문에 forceDelete 필요)
|
||||
DB::table('user_roles')->where('user_id', $user->id)->delete();
|
||||
|
||||
// 2-2. department_user 영구 삭제
|
||||
DB::table('department_user')->where('user_id', $user->id)->delete();
|
||||
|
||||
// 2-3. sales_partners 삭제 (영업파트너)
|
||||
DB::table('sales_partners')->where('user_id', $user->id)->delete();
|
||||
|
||||
// 2-4. sales_manager_documents 삭제 (영업파트너 서류)
|
||||
DB::table('sales_manager_documents')->where('user_id', $user->id)->delete();
|
||||
|
||||
// 2-5. 하위 사용자의 parent_id 해제
|
||||
User::where('parent_id', $user->id)->update(['parent_id' => null]);
|
||||
|
||||
// 3. 사용자 영구 삭제
|
||||
return $user->forceDelete();
|
||||
});
|
||||
|
||||
325
claudedocs/ai-config-설정.md
Normal file
325
claudedocs/ai-config-설정.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# AI 및 스토리지 설정 기술문서
|
||||
|
||||
> 최종 업데이트: 2026-01-29
|
||||
|
||||
## 개요
|
||||
|
||||
SAM MNG 시스템의 AI API 및 클라우드 스토리지(GCS) 설정을 관리하는 기능입니다.
|
||||
관리자 UI에서 설정하거나, `.env` 환경변수로 설정할 수 있습니다.
|
||||
|
||||
**접근 경로**: 시스템 관리 > AI 설정 (`/system/ai-config`)
|
||||
|
||||
---
|
||||
|
||||
## 지원 Provider
|
||||
|
||||
### AI Provider
|
||||
| Provider | 용도 | 기본 모델 |
|
||||
|----------|------|----------|
|
||||
| `gemini` | Google Gemini (명함 OCR, AI 어시스턴트) | gemini-2.0-flash |
|
||||
| `claude` | Anthropic Claude | claude-sonnet-4-20250514 |
|
||||
| `openai` | OpenAI GPT | gpt-4o |
|
||||
|
||||
### Storage Provider
|
||||
| Provider | 용도 |
|
||||
|----------|------|
|
||||
| `gcs` | Google Cloud Storage (음성 녹음 백업) |
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 구조
|
||||
|
||||
### 테이블: `ai_configs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE ai_configs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL, -- 설정 이름 (예: "Production Gemini")
|
||||
provider VARCHAR(20) NOT NULL, -- gemini, claude, openai, gcs
|
||||
api_key VARCHAR(255) NOT NULL, -- API 키 (GCS는 'gcs_service_account')
|
||||
model VARCHAR(100) NOT NULL, -- 모델명 (GCS는 '-')
|
||||
base_url VARCHAR(255) NULL, -- 커스텀 Base URL
|
||||
description TEXT NULL, -- 설명
|
||||
is_active BOOLEAN DEFAULT FALSE, -- 활성화 여부 (provider당 1개만)
|
||||
options JSON NULL, -- 추가 옵션 (아래 참조)
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL -- Soft Delete
|
||||
);
|
||||
```
|
||||
|
||||
### options JSON 구조
|
||||
|
||||
**AI Provider (Gemini Vertex AI)**:
|
||||
```json
|
||||
{
|
||||
"auth_type": "vertex_ai",
|
||||
"project_id": "my-gcp-project",
|
||||
"region": "us-central1",
|
||||
"service_account_path": "/var/www/sales/apikey/google_service_account.json"
|
||||
}
|
||||
```
|
||||
|
||||
**AI Provider (API Key)**:
|
||||
```json
|
||||
{
|
||||
"auth_type": "api_key"
|
||||
}
|
||||
```
|
||||
|
||||
**GCS Provider**:
|
||||
```json
|
||||
{
|
||||
"bucket_name": "my-bucket-name",
|
||||
"service_account_path": "/var/www/sales/apikey/google_service_account.json",
|
||||
"service_account_json": { ... } // 또는 JSON 직접 입력
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설정 우선순위
|
||||
|
||||
### GCS 설정 우선순위
|
||||
|
||||
```
|
||||
1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider)
|
||||
↓ 없으면
|
||||
2. 환경변수 (.env의 GCS_BUCKET_NAME, GCS_SERVICE_ACCOUNT_PATH)
|
||||
↓ 없으면
|
||||
3. 레거시 파일 (/sales/apikey/gcs_config.txt, google_service_account.json)
|
||||
```
|
||||
|
||||
### AI 설정 우선순위
|
||||
|
||||
```
|
||||
1. DB 설정 (ai_configs 테이블의 활성화된 provider)
|
||||
↓ 없으면
|
||||
2. 환경변수 (.env의 GEMINI_API_KEY 등)
|
||||
↓ 없으면
|
||||
3. 레거시 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 환경변수 설정 (.env)
|
||||
|
||||
### GCS 설정
|
||||
```env
|
||||
# Google Cloud Storage (음성 녹음 백업)
|
||||
GCS_BUCKET_NAME=your-bucket-name
|
||||
GCS_SERVICE_ACCOUNT_PATH=/var/www/sales/apikey/google_service_account.json
|
||||
GCS_USE_DB_CONFIG=true # false면 DB 설정 무시, .env만 사용
|
||||
```
|
||||
|
||||
### AI 설정 (참고)
|
||||
```env
|
||||
# Google Gemini API
|
||||
GEMINI_API_KEY=your-api-key
|
||||
GEMINI_PROJECT_ID=your-project-id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 목록
|
||||
|
||||
### 모델
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `app/Models/System/AiConfig.php` | AI 설정 Eloquent 모델 |
|
||||
|
||||
### 컨트롤러
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/System/AiConfigController.php` | CRUD + 연결 테스트 |
|
||||
|
||||
### 서비스
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `app/Services/GoogleCloudStorageService.php` | GCS 업로드/다운로드/삭제 |
|
||||
| `app/Services/GeminiService.php` | Gemini API 호출 (명함 OCR 등) |
|
||||
|
||||
### 설정
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `config/gcs.php` | GCS 환경변수 설정 |
|
||||
|
||||
### 뷰
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `resources/views/system/ai-config/index.blade.php` | AI 설정 관리 페이지 |
|
||||
|
||||
### 라우트
|
||||
```php
|
||||
// routes/web.php
|
||||
Route::prefix('system')->name('system.')->group(function () {
|
||||
Route::resource('ai-config', AiConfigController::class)->except(['show', 'create', 'edit']);
|
||||
Route::post('ai-config/{id}/toggle', [AiConfigController::class, 'toggle'])->name('ai-config.toggle');
|
||||
Route::post('ai-config/test', [AiConfigController::class, 'test'])->name('ai-config.test');
|
||||
Route::post('ai-config/test-gcs', [AiConfigController::class, 'testGcs'])->name('ai-config.test-gcs');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 메서드
|
||||
|
||||
### AiConfig 모델
|
||||
|
||||
```php
|
||||
// Provider별 활성 설정 조회
|
||||
AiConfig::getActiveGemini(); // ?AiConfig
|
||||
AiConfig::getActiveClaude(); // ?AiConfig
|
||||
AiConfig::getActiveGcs(); // ?AiConfig
|
||||
AiConfig::getActive('openai'); // ?AiConfig
|
||||
|
||||
// GCS 전용 메서드
|
||||
$config->getBucketName(); // ?string
|
||||
$config->getServiceAccountJson(); // ?array
|
||||
$config->getServiceAccountPath(); // ?string
|
||||
$config->isGcs(); // bool
|
||||
|
||||
// Vertex AI 전용 메서드
|
||||
$config->isVertexAi(); // bool
|
||||
$config->getProjectId(); // ?string
|
||||
$config->getRegion(); // string (기본: us-central1)
|
||||
```
|
||||
|
||||
### GoogleCloudStorageService
|
||||
|
||||
```php
|
||||
$gcs = new GoogleCloudStorageService();
|
||||
|
||||
// 사용 가능 여부
|
||||
$gcs->isAvailable(); // bool
|
||||
|
||||
// 설정 소스 확인
|
||||
$gcs->getConfigSource(); // 'db' | 'env' | 'legacy' | 'none'
|
||||
|
||||
// 파일 업로드
|
||||
$gcsUri = $gcs->upload($localPath, $objectName); // 'gs://bucket/object' | null
|
||||
|
||||
// 서명된 다운로드 URL (60분 유효)
|
||||
$url = $gcs->getSignedUrl($objectName, 60); // string | null
|
||||
|
||||
// 파일 삭제
|
||||
$gcs->delete($objectName); // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI 구조
|
||||
|
||||
### 탭 구성
|
||||
- **AI 설정 탭**: Gemini, Claude, OpenAI 설정 관리
|
||||
- **스토리지 설정 탭**: GCS 설정 관리
|
||||
|
||||
### 기능
|
||||
- 설정 추가/수정/삭제
|
||||
- 활성화/비활성화 토글 (provider당 1개만 활성화)
|
||||
- 연결 테스트
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### GCS 업로드 (ConsultationController)
|
||||
|
||||
```php
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
|
||||
public function uploadAudio(Request $request)
|
||||
{
|
||||
// 파일 저장
|
||||
$path = $file->store("tenant/consultations/{$tenantId}");
|
||||
$fullPath = storage_path('app/' . $path);
|
||||
|
||||
// 10MB 이상이면 GCS에도 업로드
|
||||
if ($file->getSize() > 10 * 1024 * 1024) {
|
||||
$gcs = new GoogleCloudStorageService();
|
||||
if ($gcs->isAvailable()) {
|
||||
$gcsUri = $gcs->upload($fullPath, "consultations/{$tenantId}/" . basename($path));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 명함 OCR (GeminiService)
|
||||
|
||||
```php
|
||||
use App\Services\GeminiService;
|
||||
|
||||
$gemini = new GeminiService();
|
||||
$result = $gemini->extractBusinessCard($imagePath);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 배포 가이드
|
||||
|
||||
### 서버 최초 설정
|
||||
|
||||
1. `.env` 파일에 GCS 설정 추가:
|
||||
```env
|
||||
GCS_BUCKET_NAME=production-bucket
|
||||
GCS_SERVICE_ACCOUNT_PATH=/var/www/sales/apikey/google_service_account.json
|
||||
```
|
||||
|
||||
2. 서비스 계정 JSON 파일 배치:
|
||||
```
|
||||
/var/www/sales/apikey/google_service_account.json
|
||||
```
|
||||
|
||||
3. 설정 캐시 갱신:
|
||||
```bash
|
||||
docker exec sam-mng-1 php artisan config:cache
|
||||
```
|
||||
|
||||
### 이후 배포
|
||||
- 코드 push만으로 동작 (설정 변경 불필요)
|
||||
- UI에서 오버라이드하고 싶을 때만 DB 설정 사용
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### GCS 업로드 실패
|
||||
|
||||
1. **설정 확인**:
|
||||
```php
|
||||
$gcs = new GoogleCloudStorageService();
|
||||
dd($gcs->isAvailable(), $gcs->getConfigSource(), $gcs->getBucketName());
|
||||
```
|
||||
|
||||
2. **로그 확인**:
|
||||
```bash
|
||||
docker exec sam-mng-1 tail -f storage/logs/laravel.log | grep GCS
|
||||
```
|
||||
|
||||
3. **일반적인 원인**:
|
||||
- 서비스 계정 파일 경로 오류
|
||||
- 서비스 계정에 Storage 권한 없음
|
||||
- 버킷 이름 오타
|
||||
|
||||
### AI API 연결 실패
|
||||
|
||||
1. **API 키 확인**: UI에서 "테스트" 버튼 클릭
|
||||
2. **모델명 확인**: provider별 지원 모델 확인
|
||||
3. **할당량 확인**: Google Cloud Console에서 API 할당량 확인
|
||||
|
||||
---
|
||||
|
||||
## 레거시 파일 위치 (참고)
|
||||
|
||||
Docker 컨테이너 내부 경로:
|
||||
```
|
||||
/var/www/sales/apikey/
|
||||
├── gcs_config.txt # bucket_name=xxx
|
||||
├── google_service_account.json # GCP 서비스 계정 키
|
||||
└── gemini_api_key.txt # Gemini API 키 (레거시)
|
||||
```
|
||||
|
||||
호스트 경로 (mng 기준):
|
||||
```
|
||||
../sales/apikey/
|
||||
```
|
||||
233
claudedocs/모달창_생성시_유의사항.md
Normal file
233
claudedocs/모달창_생성시_유의사항.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 모달창 생성 시 유의사항
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 SAM 프로젝트에서 모달창을 구현할 때 발생할 수 있는 문제점과 해결 방법을 정리한 것입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. pointer-events 문제
|
||||
|
||||
### 문제 상황
|
||||
|
||||
모달 배경 클릭을 방지하면서 모달 내부만 클릭 가능하게 하려고 다음과 같은 구조를 사용했을 때:
|
||||
|
||||
```html
|
||||
<!-- 문제가 발생하는 구조 -->
|
||||
<div class="fixed inset-0 z-50">
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-white rounded-xl pointer-events-auto">
|
||||
<!-- AJAX로 로드되는 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**증상**: 모달은 표시되지만 내부의 버튼, 입력 필드 등 모든 요소가 클릭되지 않음 (마치 돌덩어리처럼 동작)
|
||||
|
||||
### 원인
|
||||
|
||||
- `pointer-events-none`이 부모에 있고 `pointer-events-auto`가 자식에 있는 구조
|
||||
- AJAX로 로드된 내용이 `pointer-events-auto` div 안에 들어가도, 그 안의 요소들에 pointer-events가 제대로 상속되지 않을 수 있음
|
||||
- 특히 동적으로 로드된 HTML에서 이 문제가 자주 발생
|
||||
|
||||
### 해결 방법
|
||||
|
||||
`pointer-events-none/auto` 구조를 사용하지 않고 단순화:
|
||||
|
||||
```html
|
||||
<!-- 올바른 구조 -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50"></div>
|
||||
<!-- 모달 컨텐츠 wrapper -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="modalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-3xl">
|
||||
<!-- 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. AJAX로 로드된 HTML에서 함수 호출 문제
|
||||
|
||||
### 문제 상황
|
||||
|
||||
```html
|
||||
<!-- AJAX로 로드된 HTML -->
|
||||
<button onclick="closeModal()">닫기</button>
|
||||
```
|
||||
|
||||
**증상**: `closeModal is not defined` 오류 발생
|
||||
|
||||
### 원인
|
||||
|
||||
- 함수가 `function closeModal() {}` 형태로 정의되면 호이스팅되지만, 모듈 스코프나 블록 스코프 안에 있을 수 있음
|
||||
- AJAX로 로드된 HTML에서 전역 함수에 접근하지 못할 수 있음
|
||||
|
||||
### 해결 방법
|
||||
|
||||
**방법 1: window 객체에 명시적 등록**
|
||||
|
||||
```javascript
|
||||
// 전역 스코프에 함수 등록
|
||||
window.closeModal = function() {
|
||||
document.getElementById('modal').classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
```
|
||||
|
||||
**방법 2: 이벤트 델리게이션 (권장)**
|
||||
|
||||
```html
|
||||
<!-- HTML: data 속성 사용 -->
|
||||
<button data-close-modal>닫기</button>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript: document 레벨에서 이벤트 감지
|
||||
document.addEventListener('click', function(e) {
|
||||
const closeBtn = e.target.closest('[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
window.closeModal();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 배경 스크롤 방지
|
||||
|
||||
### 모달 열 때
|
||||
|
||||
```javascript
|
||||
document.body.style.overflow = 'hidden';
|
||||
```
|
||||
|
||||
### 모달 닫을 때
|
||||
|
||||
```javascript
|
||||
document.body.style.overflow = '';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ESC 키로 모달 닫기
|
||||
|
||||
```javascript
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
window.closeModal();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 완전한 모달 구현 예시
|
||||
|
||||
### HTML 구조
|
||||
|
||||
```html
|
||||
<!-- 모달 -->
|
||||
<div id="exampleModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
|
||||
<!-- 모달 컨텐츠 wrapper -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="exampleModalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-3xl">
|
||||
<!-- 로딩 표시 또는 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
// 전역 함수 등록
|
||||
window.openExampleModal = function(id) {
|
||||
const modal = document.getElementById('exampleModal');
|
||||
const content = document.getElementById('exampleModalContent');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// AJAX로 내용 로드
|
||||
fetch(`/api/example/${id}`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
content.innerHTML = html;
|
||||
});
|
||||
};
|
||||
|
||||
window.closeExampleModal = function() {
|
||||
document.getElementById('exampleModal').classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
|
||||
// ESC 키 지원
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
window.closeExampleModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 이벤트 델리게이션 (닫기 버튼)
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('[data-close-modal]')) {
|
||||
e.preventDefault();
|
||||
window.closeExampleModal();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### AJAX로 로드되는 부분 뷰
|
||||
|
||||
```html
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">모달 제목</h2>
|
||||
<!-- data-close-modal 속성 사용 -->
|
||||
<button type="button" data-close-modal class="text-gray-400 hover:text-gray-600">
|
||||
<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="flex justify-end gap-3 mt-6">
|
||||
<button type="button" data-close-modal class="px-4 py-2 border rounded-lg">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 체크리스트
|
||||
|
||||
모달 구현 시 다음 사항을 확인하세요:
|
||||
|
||||
- [ ] `pointer-events-none/auto` 구조를 사용하지 않음
|
||||
- [ ] 함수를 `window` 객체에 등록했음
|
||||
- [ ] 닫기 버튼에 `data-close-modal` 속성을 추가했음
|
||||
- [ ] document 레벨 이벤트 델리게이션을 설정했음
|
||||
- [ ] 모달 열 때 `body.style.overflow = 'hidden'` 설정
|
||||
- [ ] 모달 닫을 때 `body.style.overflow = ''` 복원
|
||||
- [ ] ESC 키 이벤트 리스너 등록
|
||||
- [ ] z-index가 다른 요소들과 충돌하지 않음 (보통 z-50 사용)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `/resources/views/sales/managers/index.blade.php` - 영업파트너 관리 모달 구현 예시
|
||||
- `/resources/views/sales/managers/partials/show-modal.blade.php` - 상세 모달 부분 뷰
|
||||
- `/resources/views/sales/managers/partials/edit-modal.blade.php` - 수정 모달 부분 뷰
|
||||
443
claudedocs/상품관리정보.md
Normal file
443
claudedocs/상품관리정보.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# SAM 상품관리 시스템 개발 문서
|
||||
|
||||
> 작성일: 2026-01-29
|
||||
> 목적: SAM 솔루션 상품의 가격 구조 및 계약 관리 시스템 문서화
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
SAM 상품관리 시스템은 본사(HQ)에서 SAM 솔루션 상품을 관리하고, 영업 과정에서 고객사(테넌트)에게 상품을 선택/계약하는 기능을 제공합니다.
|
||||
|
||||
### 1.1 주요 기능
|
||||
- **상품 카테고리 관리**: 업종별 상품 분류 (제조업체, 공사업체 등)
|
||||
- **상품 관리**: 개별 솔루션 상품 CRUD
|
||||
- **계약 상품 선택**: 영업 시나리오에서 고객사별 상품 선택
|
||||
- **가격 커스터마이징**: 재량권 상품의 가격 조정
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터베이스 구조
|
||||
|
||||
### 2.1 상품 카테고리 테이블 (`sales_product_categories`)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | bigint | PK |
|
||||
| `code` | varchar | 카테고리 코드 (예: `manufacturer`, `contractor`) |
|
||||
| `name` | varchar | 카테고리명 (예: "제조 업체", "공사 업체") |
|
||||
| `description` | text | 설명 |
|
||||
| `base_storage` | varchar | 기본 저장소 경로 |
|
||||
| `display_order` | int | 정렬 순서 |
|
||||
| `is_active` | boolean | 활성화 여부 |
|
||||
| `deleted_at` | timestamp | 소프트 삭제 |
|
||||
|
||||
### 2.2 상품 테이블 (`sales_products`)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | bigint | PK |
|
||||
| `category_id` | bigint | FK → sales_product_categories |
|
||||
| `code` | varchar | 상품 코드 |
|
||||
| `name` | varchar | 상품명 |
|
||||
| `description` | text | 상품 설명 |
|
||||
| `development_fee` | decimal(15,2) | **개발비** (원가) |
|
||||
| `registration_fee` | decimal(15,2) | **가입비** (고객 청구 금액) |
|
||||
| `subscription_fee` | decimal(15,2) | **월 구독료** |
|
||||
| `partner_commission_rate` | decimal(5,2) | **영업파트너 수당율** (%) |
|
||||
| `manager_commission_rate` | decimal(5,2) | **매니저 수당율** (%) |
|
||||
| `allow_flexible_pricing` | boolean | 재량권 허용 여부 |
|
||||
| `is_required` | boolean | 필수 상품 여부 |
|
||||
| `display_order` | int | 정렬 순서 |
|
||||
| `is_active` | boolean | 활성화 여부 |
|
||||
| `deleted_at` | timestamp | 소프트 삭제 |
|
||||
|
||||
### 2.3 계약 상품 테이블 (`sales_contract_products`)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | bigint | PK |
|
||||
| `tenant_id` | bigint | FK → tenants (고객사) |
|
||||
| `management_id` | bigint | FK → sales_tenant_managements |
|
||||
| `category_id` | bigint | FK → sales_product_categories |
|
||||
| `product_id` | bigint | FK → sales_products |
|
||||
| `registration_fee` | decimal(15,2) | 실제 청구 가입비 (커스텀 가능) |
|
||||
| `subscription_fee` | decimal(15,2) | 실제 청구 구독료 (커스텀 가능) |
|
||||
| `discount_rate` | decimal(5,2) | 할인율 |
|
||||
| `notes` | text | 비고 |
|
||||
| `created_by` | bigint | 등록자 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 가격 구조
|
||||
|
||||
### 3.1 가격 체계
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 가격 구조 다이어그램 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 개발비 (Development Fee) │
|
||||
│ ├── 원가 개념, 내부 관리용 │
|
||||
│ └── 예: ₩80,000,000 │
|
||||
│ │
|
||||
│ 가입비 (Registration Fee) │
|
||||
│ ├── 고객에게 청구하는 금액 │
|
||||
│ ├── 일반적으로 개발비의 25% │
|
||||
│ └── 예: ₩20,000,000 (80,000,000 × 25%) │
|
||||
│ │
|
||||
│ 월 구독료 (Subscription Fee) │
|
||||
│ ├── 매월 청구되는 구독 비용 │
|
||||
│ └── 예: ₩500,000/월 │
|
||||
│ │
|
||||
│ 수당 (Commission) │
|
||||
│ ├── 영업파트너 수당: 가입비 × 20% │
|
||||
│ ├── 매니저 수당: 가입비 × 5% │
|
||||
│ └── 총 수당율: 25% │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 가격 계산 공식
|
||||
|
||||
```php
|
||||
// 가입비 = 개발비 × 25% (기본값)
|
||||
$registration_fee = $development_fee * 0.25;
|
||||
|
||||
// 영업파트너 수당 = 가입비 × 20%
|
||||
$partner_commission = $registration_fee * 0.20;
|
||||
|
||||
// 매니저 수당 = 가입비 × 5%
|
||||
$manager_commission = $registration_fee * 0.05;
|
||||
|
||||
// 총 수당
|
||||
$total_commission = $partner_commission + $manager_commission;
|
||||
```
|
||||
|
||||
### 3.3 표시 예시 (UI)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ SAM 기본 솔루션 │
|
||||
│ │
|
||||
│ 가입비: ₩80,000,000 → ₩20,000,000 │
|
||||
│ (취소선) (할인가) │
|
||||
│ │
|
||||
│ 월 구독료: ₩500,000 │
|
||||
│ │
|
||||
│ 수당: 영업파트너 20% | 매니저 5% │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 상품 카테고리별 구성
|
||||
|
||||
### 4.1 제조 업체 (manufacturer)
|
||||
|
||||
| 상품명 | 개발비 | 가입비 | 월 구독료 | 파트너 수당 | 매니저 수당 | 필수 |
|
||||
|--------|--------|--------|-----------|-------------|-------------|------|
|
||||
| SAM 기본 솔루션 | ₩80,000,000 | ₩20,000,000 | ₩500,000 | 20% | 5% | O |
|
||||
| ERP 연동 모듈 | ₩40,000,000 | ₩10,000,000 | ₩200,000 | 20% | 5% | - |
|
||||
| MES 연동 모듈 | ₩60,000,000 | ₩15,000,000 | ₩300,000 | 20% | 5% | - |
|
||||
| 품질관리 모듈 | ₩20,000,000 | ₩5,000,000 | ₩100,000 | 20% | 5% | - |
|
||||
| 재고관리 모듈 | ₩16,000,000 | ₩4,000,000 | ₩80,000 | 20% | 5% | - |
|
||||
|
||||
### 4.2 공사 업체 (contractor)
|
||||
|
||||
| 상품명 | 개발비 | 가입비 | 월 구독료 | 파트너 수당 | 매니저 수당 | 필수 |
|
||||
|--------|--------|--------|-----------|-------------|-------------|------|
|
||||
| SAM 공사관리 | ₩60,000,000 | ₩15,000,000 | ₩400,000 | 20% | 5% | O |
|
||||
| 현장관리 모듈 | ₩24,000,000 | ₩6,000,000 | ₩150,000 | 20% | 5% | - |
|
||||
| 안전관리 모듈 | ₩20,000,000 | ₩5,000,000 | ₩100,000 | 20% | 5% | - |
|
||||
| 공정관리 모듈 | ₩32,000,000 | ₩8,000,000 | ₩200,000 | 20% | 5% | - |
|
||||
|
||||
---
|
||||
|
||||
## 5. 모델 클래스
|
||||
|
||||
### 5.1 SalesProduct 모델
|
||||
|
||||
**파일 위치**: `app/Models/Sales/SalesProduct.php`
|
||||
|
||||
```php
|
||||
class SalesProduct extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id', 'code', 'name', 'description',
|
||||
'development_fee', 'registration_fee', 'subscription_fee',
|
||||
'partner_commission_rate', 'manager_commission_rate',
|
||||
'allow_flexible_pricing', 'is_required',
|
||||
'display_order', 'is_active',
|
||||
];
|
||||
|
||||
// Accessors
|
||||
public function getTotalCommissionRateAttribute(): float
|
||||
{
|
||||
return $this->partner_commission_rate + $this->manager_commission_rate;
|
||||
}
|
||||
|
||||
public function getCommissionAttribute(): float
|
||||
{
|
||||
return $this->development_fee * ($this->total_commission_rate / 100);
|
||||
}
|
||||
|
||||
public function getFormattedDevelopmentFeeAttribute(): string
|
||||
{
|
||||
return '₩' . number_format($this->development_fee);
|
||||
}
|
||||
|
||||
public function getFormattedRegistrationFeeAttribute(): string
|
||||
{
|
||||
return '₩' . number_format($this->registration_fee);
|
||||
}
|
||||
|
||||
public function getFormattedSubscriptionFeeAttribute(): string
|
||||
{
|
||||
return '₩' . number_format($this->subscription_fee);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 SalesProductCategory 모델
|
||||
|
||||
**파일 위치**: `app/Models/Sales/SalesProductCategory.php`
|
||||
|
||||
```php
|
||||
class SalesProductCategory extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'code', 'name', 'description',
|
||||
'base_storage', 'display_order', 'is_active',
|
||||
];
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesProduct::class, 'category_id');
|
||||
}
|
||||
|
||||
public function activeProducts(): HasMany
|
||||
{
|
||||
return $this->products()->where('is_active', true)->orderBy('display_order');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 SalesContractProduct 모델
|
||||
|
||||
**파일 위치**: `app/Models/Sales/SalesContractProduct.php`
|
||||
|
||||
```php
|
||||
class SalesContractProduct extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_id', 'management_id', 'category_id', 'product_id',
|
||||
'registration_fee', 'subscription_fee',
|
||||
'discount_rate', 'notes', 'created_by',
|
||||
];
|
||||
|
||||
// 테넌트별 총 가입비
|
||||
public static function getTotalRegistrationFee(int $tenantId): float
|
||||
{
|
||||
return self::where('tenant_id', $tenantId)->sum('registration_fee') ?? 0;
|
||||
}
|
||||
|
||||
// 테넌트별 총 구독료
|
||||
public static function getTotalSubscriptionFee(int $tenantId): float
|
||||
{
|
||||
return self::where('tenant_id', $tenantId)->sum('subscription_fee') ?? 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API 엔드포인트
|
||||
|
||||
### 6.1 상품 관리 (HQ 전용)
|
||||
|
||||
| Method | URI | 설명 |
|
||||
|--------|-----|------|
|
||||
| GET | `/sales/products` | 상품 목록 페이지 |
|
||||
| POST | `/sales/products` | 상품 생성 |
|
||||
| PUT | `/sales/products/{id}` | 상품 수정 |
|
||||
| DELETE | `/sales/products/{id}` | 상품 삭제 |
|
||||
| POST | `/sales/products/categories` | 카테고리 생성 |
|
||||
| PUT | `/sales/products/categories/{id}` | 카테고리 수정 |
|
||||
| DELETE | `/sales/products/categories/{id}` | 카테고리 삭제 |
|
||||
|
||||
### 6.2 계약 상품 선택 (영업 시나리오)
|
||||
|
||||
| Method | URI | 설명 |
|
||||
|--------|-----|------|
|
||||
| POST | `/sales/contracts/products` | 상품 선택 저장 |
|
||||
|
||||
**요청 본문**:
|
||||
```json
|
||||
{
|
||||
"tenant_id": 123,
|
||||
"category_id": 1,
|
||||
"products": [
|
||||
{
|
||||
"product_id": 1,
|
||||
"category_id": 1,
|
||||
"registration_fee": 20000000,
|
||||
"subscription_fee": 500000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 영업 시나리오 연동
|
||||
|
||||
### 7.1 계약 체결 단계 (Step 6)
|
||||
|
||||
영업 시나리오의 6단계 "계약 체결"에서 상품 선택 UI가 표시됩니다.
|
||||
|
||||
**파일 위치**: `resources/views/sales/modals/partials/product-selection.blade.php`
|
||||
|
||||
### 7.2 상품 선택 흐름
|
||||
|
||||
```
|
||||
1. 영업 시나리오 모달 열기
|
||||
↓
|
||||
2. "계약 체결" 탭 선택
|
||||
↓
|
||||
3. 카테고리 탭 선택 (제조업체/공사업체)
|
||||
↓
|
||||
4. 상품 체크박스 선택/해제
|
||||
↓
|
||||
5. 합계 자동 계산 (선택된 카테고리 기준)
|
||||
↓
|
||||
6. "상품 선택 저장" 버튼 클릭
|
||||
↓
|
||||
7. sales_contract_products 테이블에 저장
|
||||
```
|
||||
|
||||
### 7.3 내 계약 현황 표시
|
||||
|
||||
**파일 위치**: `resources/views/sales/dashboard/partials/tenant-list.blade.php`
|
||||
|
||||
각 테넌트 행에 계약 금액 정보가 표시됩니다:
|
||||
- 총 가입비: `SalesContractProduct::getTotalRegistrationFee($tenantId)`
|
||||
- 총 구독료: `SalesContractProduct::getTotalSubscriptionFee($tenantId)`
|
||||
|
||||
---
|
||||
|
||||
## 8. 주요 속성 설명
|
||||
|
||||
### 8.1 `is_required` (필수 상품)
|
||||
|
||||
- `true`: 해제 불가, 항상 선택된 상태
|
||||
- 예: "SAM 기본 솔루션"은 필수
|
||||
|
||||
### 8.2 `allow_flexible_pricing` (재량권)
|
||||
|
||||
- `true`: 영업 담당자가 가격 조정 가능
|
||||
- UI에서 "재량권" 뱃지로 표시
|
||||
|
||||
### 8.3 개발비 vs 가입비
|
||||
|
||||
| 구분 | 개발비 (development_fee) | 가입비 (registration_fee) |
|
||||
|------|-------------------------|--------------------------|
|
||||
| 용도 | 내부 원가 관리 | 고객 청구 금액 |
|
||||
| 표시 | 취소선으로 표시 | 실제 금액으로 표시 |
|
||||
| 비율 | 100% (기준) | 25% (기본) |
|
||||
| 수당 계산 | 기준 금액 | - |
|
||||
|
||||
---
|
||||
|
||||
## 9. 수당 계산 예시
|
||||
|
||||
### 9.1 단일 상품 계약
|
||||
|
||||
```
|
||||
상품: SAM 기본 솔루션
|
||||
개발비: ₩80,000,000
|
||||
가입비: ₩20,000,000
|
||||
|
||||
영업파트너 수당 = ₩20,000,000 × 20% = ₩4,000,000
|
||||
매니저 수당 = ₩20,000,000 × 5% = ₩1,000,000
|
||||
총 수당 = ₩5,000,000
|
||||
```
|
||||
|
||||
### 9.2 복수 상품 계약
|
||||
|
||||
```
|
||||
상품1: SAM 기본 솔루션 (가입비 ₩20,000,000)
|
||||
상품2: ERP 연동 모듈 (가입비 ₩10,000,000)
|
||||
상품3: 품질관리 모듈 (가입비 ₩5,000,000)
|
||||
|
||||
총 가입비 = ₩35,000,000
|
||||
|
||||
영업파트너 수당 = ₩35,000,000 × 20% = ₩7,000,000
|
||||
매니저 수당 = ₩35,000,000 × 5% = ₩1,750,000
|
||||
총 수당 = ₩8,750,000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 확장 가능성
|
||||
|
||||
### 10.1 추가 개발 가능 기능
|
||||
|
||||
1. **수당 정산 시스템**: 월별 수당 정산 및 지급 관리
|
||||
2. **가격 이력 관리**: 상품 가격 변경 이력 추적
|
||||
3. **할인 정책**: 다양한 할인 유형 (볼륨, 기간, 특별)
|
||||
4. **번들 상품**: 여러 상품을 묶은 패키지 상품
|
||||
5. **구독 관리**: 구독 갱신, 해지, 업그레이드 관리
|
||||
|
||||
### 10.2 API 확장
|
||||
|
||||
```php
|
||||
// 수당 계산 API
|
||||
GET /api/sales/commissions/calculate?tenant_id={id}
|
||||
|
||||
// 가격 이력 조회
|
||||
GET /api/sales/products/{id}/price-history
|
||||
|
||||
// 할인 적용
|
||||
POST /api/sales/contracts/{id}/apply-discount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 관련 파일 목록
|
||||
|
||||
### 11.1 모델
|
||||
- `app/Models/Sales/SalesProduct.php`
|
||||
- `app/Models/Sales/SalesProductCategory.php`
|
||||
- `app/Models/Sales/SalesContractProduct.php`
|
||||
|
||||
### 11.2 컨트롤러
|
||||
- `app/Http/Controllers/Sales/SalesProductController.php`
|
||||
- `app/Http/Controllers/Sales/SalesContractController.php`
|
||||
|
||||
### 11.3 뷰
|
||||
- `resources/views/sales/products/index.blade.php` (상품관리 페이지)
|
||||
- `resources/views/sales/products/partials/product-list.blade.php` (상품 목록)
|
||||
- `resources/views/sales/modals/partials/product-selection.blade.php` (상품 선택)
|
||||
- `resources/views/sales/dashboard/partials/tenant-list.blade.php` (계약 현황)
|
||||
|
||||
### 11.4 마이그레이션 (API 프로젝트)
|
||||
- `database/migrations/xxxx_create_sales_product_categories_table.php`
|
||||
- `database/migrations/xxxx_create_sales_products_table.php`
|
||||
- `database/migrations/xxxx_create_sales_contract_products_table.php`
|
||||
- `database/migrations/xxxx_add_registration_fee_to_sales_products_table.php`
|
||||
- `database/migrations/xxxx_add_partner_manager_commission_to_sales_products_table.php`
|
||||
|
||||
---
|
||||
|
||||
## 12. 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 | 작성자 |
|
||||
|------|----------|--------|
|
||||
| 2026-01-29 | 최초 문서 작성 | Claude |
|
||||
| 2026-01-29 | 가입비/개발비 분리, 수당율 분리 (파트너/매니저) | Claude |
|
||||
164
claudedocs/홈택스 매입매출 조회성공.md
Normal file
164
claudedocs/홈택스 매입매출 조회성공.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 바로빌 홈택스 매입/매출 API 연동 - 문제 해결 기록
|
||||
|
||||
> 작성일: 2026-01-28
|
||||
> 해결 소요: 약 2일 (2026-01-26 ~ 2026-01-28)
|
||||
|
||||
## 개요
|
||||
|
||||
바로빌 API를 통해 홈택스 매입/매출 세금계산서를 조회하는 기능 개발 중 발생한 오류와 해결 과정을 기록합니다.
|
||||
|
||||
## 사용 API
|
||||
|
||||
| API 메소드 | 용도 |
|
||||
|-----------|------|
|
||||
| `GetPeriodTaxInvoiceSalesList` | 기간별 매출 세금계산서 목록 조회 |
|
||||
| `GetPeriodTaxInvoicePurchaseList` | 기간별 매입 세금계산서 목록 조회 |
|
||||
|
||||
## 발생한 오류들
|
||||
|
||||
### 1. -10008 날짜형식 오류
|
||||
|
||||
**오류 메시지:**
|
||||
```
|
||||
-10008 날짜형식이 잘못되었습니다.
|
||||
```
|
||||
|
||||
**원인:**
|
||||
날짜 파라미터에 하이픈(`-`)이 포함됨
|
||||
|
||||
**잘못된 예:**
|
||||
```json
|
||||
{
|
||||
"StartDate": "2026-01-01",
|
||||
"EndDate": "2026-01-26"
|
||||
}
|
||||
```
|
||||
|
||||
**해결:**
|
||||
```json
|
||||
{
|
||||
"StartDate": "20260101",
|
||||
"EndDate": "20260126"
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 코드:**
|
||||
```php
|
||||
// 하이픈 없는 YYYYMMDD 형식 사용
|
||||
$startDate = date('Ymd', strtotime('-1 month'));
|
||||
$endDate = date('Ymd');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. -11010 과세형태 오류
|
||||
|
||||
**오류 메시지:**
|
||||
```
|
||||
-11010 과세형태가 잘못되었습니다. (TaxType)
|
||||
```
|
||||
|
||||
**원인:**
|
||||
`TaxType=0` (전체)은 바로빌 API에서 **지원하지 않음**
|
||||
|
||||
**잘못된 예:**
|
||||
```json
|
||||
{
|
||||
"TaxType": 0
|
||||
}
|
||||
```
|
||||
|
||||
**바로빌 API TaxType 값:**
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| 0 | ❌ 미지원 |
|
||||
| 1 | 과세 + 영세 |
|
||||
| 3 | 면세 |
|
||||
|
||||
**해결:**
|
||||
전체 조회 시 TaxType=1과 TaxType=3을 **각각 조회하여 합침**
|
||||
|
||||
```php
|
||||
// TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침
|
||||
$taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType];
|
||||
$allInvoices = [];
|
||||
|
||||
foreach ($taxTypesToQuery as $queryTaxType) {
|
||||
$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $queryTaxType,
|
||||
// ...
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
$parsed = $this->parseInvoices($result['data'], 'sales');
|
||||
$allInvoices = array_merge($allInvoices, $parsed['invoices']);
|
||||
}
|
||||
}
|
||||
|
||||
// 작성일 기준 최신순 정렬
|
||||
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. DateType 권장사항
|
||||
|
||||
**바로빌 권장:**
|
||||
`DateType=3` (전송일자) 사용 권장
|
||||
|
||||
**DateType 값:**
|
||||
| 값 | 의미 | 비고 |
|
||||
|----|------|------|
|
||||
| 1 | 작성일 기준 | - |
|
||||
| 3 | 전송일자 기준 | **권장** |
|
||||
|
||||
**적용:**
|
||||
```php
|
||||
$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $queryTaxType,
|
||||
'DateType' => 3, // 전송일자 기준 (권장)
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 최종 작동 파라미터
|
||||
|
||||
```json
|
||||
{
|
||||
"CERTKEY": "인증키",
|
||||
"CorpNum": "사업자번호",
|
||||
"UserID": "바로빌ID",
|
||||
"TaxType": 1,
|
||||
"DateType": 3,
|
||||
"StartDate": "20251231",
|
||||
"EndDate": "20260130",
|
||||
"CountPerPage": 100,
|
||||
"CurrentPage": 1
|
||||
}
|
||||
```
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `app/Http/Controllers/Barobill/HometaxController.php`
|
||||
- `sales()` - 매출 조회
|
||||
- `purchases()` - 매입 조회
|
||||
- `diagnose()` - 서비스 진단
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- 바로빌 개발자 문서: https://dev.barobill.co.kr/docs/taxinvoice
|
||||
- 바로빌 운영센터 메일 (2026-01-27, 2026-01-28)
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **API 문서를 꼼꼼히 확인** - TaxType=0이 전체를 의미할 것 같지만 실제로는 미지원
|
||||
2. **날짜 형식 주의** - 한국 API는 하이픈 없는 YYYYMMDD 형식을 많이 사용
|
||||
3. **권장사항 따르기** - DateType=3 (전송일자) 사용 권장
|
||||
4. **에러 발생 시 바로빌 고객센터 문의** - 로그를 분석해서 정확한 원인을 알려줌
|
||||
21
config/gcs.php
Normal file
21
config/gcs.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Google Cloud Storage 설정
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. DB 설정 (ai_configs 테이블) - UI에서 오버라이드 가능
|
||||
* 2. 환경변수 (.env)
|
||||
* 3. 레거시 파일 (/sales/apikey/)
|
||||
*/
|
||||
|
||||
return [
|
||||
// 버킷 이름
|
||||
'bucket_name' => env('GCS_BUCKET_NAME'),
|
||||
|
||||
// 서비스 계정 파일 경로
|
||||
'service_account_path' => env('GCS_SERVICE_ACCOUNT_PATH', '/var/www/sales/apikey/google_service_account.json'),
|
||||
|
||||
// DB 설정 사용 여부 (false면 .env만 사용)
|
||||
'use_db_config' => env('GCS_USE_DB_CONFIG', true),
|
||||
];
|
||||
435
config/sales_scenario.php
Normal file
435
config/sales_scenario.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 영업 시나리오 설정
|
||||
*
|
||||
* 영업 진행 및 매니저 상담 프로세스의 단계별 체크리스트 정의
|
||||
*/
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 영업 시나리오 단계 (SALES_SCENARIO_STEPS)
|
||||
|--------------------------------------------------------------------------
|
||||
| 영업 담당자가 고객사와 계약을 체결하기까지의 6단계 프로세스
|
||||
*/
|
||||
'sales_steps' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => '사전 준비',
|
||||
'subtitle' => 'Preparation',
|
||||
'icon' => 'search',
|
||||
'color' => 'blue',
|
||||
'bg_class' => 'bg-blue-100',
|
||||
'text_class' => 'text-blue-600',
|
||||
'description' => '고객사를 만나기 전, 철저한 분석을 통해 성공 확률을 높이는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'prep_1',
|
||||
'title' => '고객사 심층 분석',
|
||||
'detail' => '홈페이지, 뉴스 등을 통해 이슈와 비전을 파악하세요.',
|
||||
'pro_tip' => '직원들의 불만 사항을 미리 파악하면 미팅 시 강력한 무기가 됩니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'prep_2',
|
||||
'title' => '재무 건전성 확인',
|
||||
'detail' => '매출액, 영업이익 추이를 확인하고 IT 투자 여력을 가늠해보세요.',
|
||||
'pro_tip' => '성장 추세라면 \'확장성\'과 \'관리 효율\'을 강조하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'prep_3',
|
||||
'title' => '경쟁사 및 시장 동향',
|
||||
'detail' => '핵심 기능에 집중하여 도입 속도가 빠르다는 점을 정리하세요.',
|
||||
'pro_tip' => '경쟁사를 비방하기보다 차별화된 가치를 제시하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'prep_4',
|
||||
'title' => '가설 수립 (Hypothesis)',
|
||||
'detail' => '구체적인 페인포인트 가설을 세우고 질문을 준비하세요.',
|
||||
'pro_tip' => '\'만약 ~하다면\' 화법으로 고객의 \'Yes\'를 유도하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'title' => '접근 및 탐색',
|
||||
'subtitle' => 'Approach',
|
||||
'icon' => 'phone',
|
||||
'color' => 'indigo',
|
||||
'bg_class' => 'bg-indigo-100',
|
||||
'text_class' => 'text-indigo-600',
|
||||
'description' => '담당자와의 첫 접점을 만들고, 미팅 기회를 확보하는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'approach_1',
|
||||
'title' => 'Key-man 식별 및 컨택',
|
||||
'detail' => '실무 책임자(팀장급)와 의사결정권자(임원급) 라인을 파악하세요.',
|
||||
'pro_tip' => '전달드릴 자료가 있다고 하여 Gatekeeper를 통과하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'approach_2',
|
||||
'title' => '맞춤형 콜드메일/콜',
|
||||
'detail' => '사전 조사 내용을 바탕으로 해결 방안을 제안하세요.',
|
||||
'pro_tip' => '제목에 고객사 이름을 넣어 클릭률을 높이세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'approach_3',
|
||||
'title' => '미팅 일정 확정',
|
||||
'detail' => '인사이트 공유를 목적으로 미팅을 제안하세요.',
|
||||
'pro_tip' => '두 가지 시간대를 제시하여 양자택일을 유도하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'title' => '현장 진단',
|
||||
'subtitle' => 'Diagnosis',
|
||||
'icon' => 'clipboard-check',
|
||||
'color' => 'purple',
|
||||
'bg_class' => 'bg-purple-100',
|
||||
'text_class' => 'text-purple-600',
|
||||
'description' => '고객의 업무 현장을 직접 확인하고 진짜 문제를 찾아내는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'diag_1',
|
||||
'title' => 'AS-IS 프로세스 맵핑',
|
||||
'detail' => '고객과 함께 업무 흐름도를 그리며 병목을 찾으세요.',
|
||||
'pro_tip' => '고객 스스로 문제를 깨닫게 하는 것이 가장 효과적입니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'diag_2',
|
||||
'title' => '비효율/리스크 식별',
|
||||
'detail' => '데이터 누락, 중복 입력 등 리스크를 수치화하세요.',
|
||||
'pro_tip' => '불편함을 시간과 비용으로 환산하여 설명하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'diag_3',
|
||||
'title' => 'To-Be 이미지 스케치',
|
||||
'detail' => '도입 후 업무가 어떻게 간소화될지 시각화하세요.',
|
||||
'pro_tip' => '비포/애프터의 극명한 차이를 보여주세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'title' => '솔루션 제안',
|
||||
'subtitle' => 'Proposal',
|
||||
'icon' => 'presentation',
|
||||
'color' => 'pink',
|
||||
'bg_class' => 'bg-pink-100',
|
||||
'text_class' => 'text-pink-600',
|
||||
'description' => 'SAM을 통해 고객의 문제를 어떻게 해결할 수 있는지 증명하는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'proposal_1',
|
||||
'title' => '맞춤형 데모 시연',
|
||||
'detail' => '핵심 기능을 위주로 고객사 데이터를 넣어 시연하세요.',
|
||||
'pro_tip' => '고객사 로고를 넣어 \'이미 우리 것\'이라는 느낌을 주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'proposal_2',
|
||||
'title' => 'ROI 분석 보고서',
|
||||
'detail' => '비용 대비 절감 가능한 수치를 산출하여 증명하세요.',
|
||||
'pro_tip' => '보수적인 ROI가 훨씬 더 높은 신뢰를 줍니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'proposal_3',
|
||||
'title' => '단계별 도입 로드맵',
|
||||
'detail' => '부담을 줄이기 위해 단계적 확산 방안을 제시하세요.',
|
||||
'pro_tip' => '1단계는 핵심 문제 해결에만 집중하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 5,
|
||||
'title' => '협상 및 조율',
|
||||
'subtitle' => 'Negotiation',
|
||||
'icon' => 'scale',
|
||||
'color' => 'orange',
|
||||
'bg_class' => 'bg-orange-100',
|
||||
'text_class' => 'text-orange-600',
|
||||
'description' => '도입을 가로막는 장애물을 제거하고 조건을 합의하는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'nego_1',
|
||||
'title' => '가격/조건 협상',
|
||||
'detail' => '할인 대신 범위나 기간 조정 등으로 합의하세요.',
|
||||
'pro_tip' => 'Give & Take 원칙을 지키며 기대를 관리하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_2',
|
||||
'title' => '의사결정권자 설득',
|
||||
'detail' => 'CEO/CFO의 관심사에 맞는 보고용 장표를 제공하세요.',
|
||||
'pro_tip' => '실무자가 내부 보고 사업을 잘하게 돕는 것이 핵심입니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 6,
|
||||
'title' => '계약 체결',
|
||||
'subtitle' => 'Closing',
|
||||
'icon' => 'file-signature',
|
||||
'color' => 'green',
|
||||
'bg_class' => 'bg-green-100',
|
||||
'text_class' => 'text-green-600',
|
||||
'description' => '공식적인 파트너십을 맺고 법적 효력을 발생시키는 단계입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'close_1',
|
||||
'title' => '계약 체결 완료',
|
||||
'detail' => '계약서 날인/교부, 세금계산서 발행, 후속 지원 일정까지 한 번에 진행하세요.',
|
||||
'pro_tip' => '가입비 입금이 완료되어야 매니저에게 프로젝트가 이관됩니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 매니저 시나리오 단계 (MANAGER_SCENARIO_STEPS)
|
||||
|--------------------------------------------------------------------------
|
||||
| 매니저가 프로젝트를 인수받아 착수하기까지의 6단계 프로세스
|
||||
*/
|
||||
'manager_steps' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => '영업 이관',
|
||||
'subtitle' => 'Handover',
|
||||
'icon' => 'arrow-right-left',
|
||||
'color' => 'blue',
|
||||
'bg_class' => 'bg-blue-100',
|
||||
'text_class' => 'text-blue-600',
|
||||
'description' => '영업팀으로부터 고객 정보를 전달받고, 프로젝트의 배경과 핵심 요구사항을 파악하는 단계입니다.',
|
||||
'tips' => '잘못된 시작은 엉뚱한 결말을 낳습니다. 영업팀의 약속을 검증하세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'handover_1',
|
||||
'title' => '영업 히스토리 리뷰',
|
||||
'detail' => '영업 담당자가 작성한 미팅록, 고객의 페인포인트, 예산 범위, 예상 일정 등을 꼼꼼히 확인하세요.',
|
||||
'pro_tip' => '영업 담당자에게 \'고객이 가장 꽂힌 포인트\'가 무엇인지 꼭 물어보세요. 그게 프로젝트의 CSF입니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'handover_2',
|
||||
'title' => '고객사 기본 정보 파악',
|
||||
'detail' => '고객사의 업종, 규모, 주요 경쟁사 등을 파악하여 커뮤니케이션 톤앤매너를 준비하세요.',
|
||||
'pro_tip' => 'IT 지식이 부족한 고객이라면 전문 용어 사용을 자제하고 쉬운 비유를 준비해야 합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'handover_3',
|
||||
'title' => 'RFP/요구사항 문서 분석',
|
||||
'detail' => '고객이 전달한 요구사항 문서(RFP 등)가 있다면 기술적으로 실현 가능한지 1차 검토하세요.',
|
||||
'pro_tip' => '모호한 문장을 찾아내어 구체적인 수치나 기능으로 정의할 준비를 하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'handover_4',
|
||||
'title' => '내부 킥오프 (영업-매니저)',
|
||||
'detail' => '영업팀과 함께 프로젝트의 리스크 요인(까다로운 담당자 등)을 사전에 공유받으세요.',
|
||||
'pro_tip' => '영업 단계에서 \'무리하게 약속한 기능\'이 있는지 반드시 체크해야 합니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'title' => '요구사항 파악',
|
||||
'subtitle' => 'Requirements',
|
||||
'icon' => 'search',
|
||||
'color' => 'indigo',
|
||||
'bg_class' => 'bg-indigo-100',
|
||||
'text_class' => 'text-indigo-600',
|
||||
'description' => '고객과 직접 만나 구체적인 니즈를 청취하고, 숨겨진 요구사항까지 발굴하는 단계입니다.',
|
||||
'tips' => '고객은 자기가 뭘 원하는지 모를 때가 많습니다. 질문으로 답을 찾아주세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'req_1',
|
||||
'title' => '고객 인터뷰 및 실사',
|
||||
'detail' => '현업 담당자를 만나 실제 업무 프로세스를 확인하고 시스템이 필요한 진짜 이유를 찾으세요.',
|
||||
'pro_tip' => '\'왜 이 기능이 필요하세요?\'라고 3번 물어보세요(5 Whys). 목적을 찾아야 합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'req_2',
|
||||
'title' => '요구사항 구체화 (Scope)',
|
||||
'detail' => '고객의 요구사항을 기능 단위로 쪼개고 우선순위(Must/Should/Could)를 매기세요.',
|
||||
'pro_tip' => '\'오픈 시점에 반드시 필요한 기능\'과 \'추후 고도화할 기능\'을 명확히 구분해 주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'req_3',
|
||||
'title' => '제약 사항 확인',
|
||||
'detail' => '예산, 일정, 레거시 시스템 연동, 보안 규정 등 프로젝트의 제약 조건을 명확히 하세요.',
|
||||
'pro_tip' => '특히 \'데이터 이관\' 이슈를 조심하세요. 엑셀 데이터가 엉망인 경우가 태반입니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'req_4',
|
||||
'title' => '유사 레퍼런스 제시',
|
||||
'detail' => '비슷한 고민을 했던 다른 고객사의 해결 사례를 보여주며 제안하는 방향의 신뢰를 얻으세요.',
|
||||
'pro_tip' => '\'A사도 이렇게 푸셨습니다\'라는 한마디가 백 마디 설명보다 강력합니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'title' => '개발자 협의',
|
||||
'subtitle' => 'Dev Consult',
|
||||
'icon' => 'code',
|
||||
'color' => 'purple',
|
||||
'bg_class' => 'bg-purple-100',
|
||||
'text_class' => 'text-purple-600',
|
||||
'description' => '파악된 요구사항을 개발팀에 전달하고 기술적 실현 가능성과 공수를 산정합니다.',
|
||||
'tips' => '개발자는 \'기능\'을 만들지만, 매니저는 \'가치\'를 만듭니다. 통역사가 되어주세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'dev_1',
|
||||
'title' => '요구사항 기술 검토',
|
||||
'detail' => '개발 리드와 함께 고객의 요구사항이 기술적으로 구현 가능한지 검토하세요.',
|
||||
'pro_tip' => '개발자가 \'안 돼요\'라고 하면 \'왜 안 되는지\', \'대안은 무엇인지\'를 반드시 물어보세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'dev_2',
|
||||
'title' => '공수 산정 (Estimation)',
|
||||
'detail' => '기능별 개발 예상 시간(M/M)을 산출하고 필요한 리소스를 파악하세요.',
|
||||
'pro_tip' => '개발 공수는 항상 버퍼(Buffer)를 20% 정도 두세요. 버그나 스펙 변경은 반드시 일어납니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'dev_3',
|
||||
'title' => '아키텍처/스택 선정',
|
||||
'detail' => '프로젝트에 적합한 기술 스택과 시스템 아키텍처를 확정하세요.',
|
||||
'pro_tip' => '최신 기술보다 유지보수 용이성과 개발팀의 숙련도를 최우선으로 고려하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'dev_4',
|
||||
'title' => '리스크 식별 및 대안 수립',
|
||||
'detail' => '기술적 난이도가 높은 기능 등 리스크를 식별하고 대안(Plan B)을 마련하세요.',
|
||||
'pro_tip' => '리스크는 감추지 말고 공유해야 합니다. 미리 말하면 관리입니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'title' => '제안 및 견적',
|
||||
'subtitle' => 'Proposal',
|
||||
'icon' => 'file-text',
|
||||
'color' => 'pink',
|
||||
'bg_class' => 'bg-pink-100',
|
||||
'text_class' => 'text-pink-600',
|
||||
'description' => '개발팀 검토 내용을 바탕으로 수행 계획서(SOW)와 견적서를 작성하여 제안합니다.',
|
||||
'tips' => '견적서는 숫자가 아니라 \'신뢰\'를 담아야 합니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'prop_1',
|
||||
'title' => 'WBS 및 일정 계획 수립',
|
||||
'detail' => '분석/설계/개발/테스트/오픈 등 단계별 상세 일정을 수립하세요.',
|
||||
'pro_tip' => '고객의 검수(UAT) 기간을 충분히 잡으세요. 고객은 생각보다 바빠서 피드백이 늦어집니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'prop_2',
|
||||
'title' => '견적서(Quotation) 작성',
|
||||
'detail' => '개발 공수, 솔루션 비용, 인프라 비용 등을 포함한 상세 견적서를 작성하세요.',
|
||||
'pro_tip' => '\'기능별 상세 견적\'을 제공하면 신뢰도가 높아지고 네고 방어에도 유리합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'prop_3',
|
||||
'title' => '제안서(SOW) 작성',
|
||||
'detail' => '범위(Scope), 수행 방법론, 산출물 목록 등을 명시한 제안서를 작성하세요.',
|
||||
'pro_tip' => '\'제외 범위(Out of Scope)\'를 명확히 적으세요. 나중에 딴소리 듣지 않으려면요.',
|
||||
],
|
||||
[
|
||||
'id' => 'prop_4',
|
||||
'title' => '제안 발표 (PT)',
|
||||
'detail' => '고객에게 제안 내용을 설명하고 우리가 가장 적임자임을 설득하세요.',
|
||||
'pro_tip' => '발표 자료는 \'고객의 언어\'로 작성하세요. 기술 용어 남발은 금물입니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 5,
|
||||
'title' => '조율 및 협상',
|
||||
'subtitle' => 'Negotiation',
|
||||
'icon' => 'scale',
|
||||
'color' => 'orange',
|
||||
'bg_class' => 'bg-orange-100',
|
||||
'text_class' => 'text-orange-600',
|
||||
'description' => '제안 내용을 바탕으로 고객과 범위, 일정, 비용을 최종 조율하는 단계입니다.',
|
||||
'tips' => '협상은 이기는 게 아니라, 같이 갈 수 있는 길을 찾는 것입니다.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'nego_m_1',
|
||||
'title' => '범위 및 일정 조정',
|
||||
'detail' => '예산이나 일정에 맞춰 기능을 가감하거나 단계별 오픈 전략을 협의하세요.',
|
||||
'pro_tip' => '무리한 일정 단축은 단호하게 거절하되, \'선오픈\'과 같은 대안을 제시하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_m_2',
|
||||
'title' => '추가 요구사항 대응',
|
||||
'detail' => '제안 과정에서 나온 추가 요구사항에 대해 비용 청구 여부를 결정하세요.',
|
||||
'pro_tip' => '서비스로 해주더라도 \'원래 얼마짜리인데 이번만 하는 것\'이라고 인지시키세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_m_3',
|
||||
'title' => 'R&R 명확화',
|
||||
'detail' => '우리 회사와 고객사가 각각 해야 할 역할을 명문화하세요.',
|
||||
'pro_tip' => '프로젝트 지연의 절반은 고객의 자료 전달 지연입니다. 숙제를 명확히 알려주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'nego_m_4',
|
||||
'title' => '최종 합의 도출',
|
||||
'detail' => '모든 쟁점 사항을 정리하고 최종 합의된 내용을 문서로 남기세요.',
|
||||
'pro_tip' => '구두 합의는 힘이 없습니다. 반드시 이메일이나 회의록으로 남기세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 6,
|
||||
'title' => '착수 및 계약',
|
||||
'subtitle' => 'Kickoff',
|
||||
'icon' => 'flag',
|
||||
'color' => 'green',
|
||||
'bg_class' => 'bg-green-100',
|
||||
'text_class' => 'text-green-600',
|
||||
'description' => '계약을 체결하고 프로젝트를 공식적으로 시작하는 단계입니다.',
|
||||
'tips' => '시작이 좋아야 끝도 좋습니다. 룰을 명확히 세우세요.',
|
||||
'checkpoints' => [
|
||||
[
|
||||
'id' => 'kick_1',
|
||||
'title' => '계약서 검토 및 날인',
|
||||
'detail' => '과업지시서, 기술협약서 등 계약 부속 서류를 꼼꼼히 챙기세요.',
|
||||
'pro_tip' => '계약서에 \'검수 조건\'을 명확히 넣으세요. 실현 가능한 조건이어야 합니다.',
|
||||
],
|
||||
[
|
||||
'id' => 'kick_2',
|
||||
'title' => '프로젝트 팀 구성',
|
||||
'detail' => '수행 인력을 확정하고 내부 킥오프를 진행하세요.',
|
||||
'pro_tip' => '팀원들에게 프로젝트 배경뿐만 아니라 \'고객의 성향\'도 공유해 주세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'kick_3',
|
||||
'title' => '착수 보고회 (Kick-off)',
|
||||
'detail' => '전원이 모여 프로젝트의 목표, 일정, 커뮤니케이션 룰을 공유하세요.',
|
||||
'pro_tip' => '첫인상이 전문적이어야 프로젝트가 순탄합니다. 깔끔하게 준비하세요.',
|
||||
],
|
||||
[
|
||||
'id' => 'kick_4',
|
||||
'title' => '협업 도구 세팅',
|
||||
'detail' => 'Jira, Slack 등 협업 도구를 세팅하고 고객을 초대하세요.',
|
||||
'pro_tip' => '소통 채널 단일화가 성공의 열쇠입니다. 간단 가이드를 제공하세요.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 아이콘 매핑 (Heroicons SVG)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'icons' => [
|
||||
'search' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
|
||||
'phone' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />',
|
||||
'clipboard-check' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
|
||||
'presentation' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />',
|
||||
'scale' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />',
|
||||
'file-signature' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
|
||||
'arrow-right-left' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />',
|
||||
'code' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
|
||||
'file-text' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
|
||||
'flag' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />',
|
||||
],
|
||||
];
|
||||
@@ -49,6 +49,30 @@ public function run(): void
|
||||
'is_active' => true,
|
||||
'sort_order' => 3,
|
||||
],
|
||||
[
|
||||
'service_type' => 'hometax_purchase',
|
||||
'name' => '홈택스 매입',
|
||||
'description' => '월정액 33,000원(VAT포함) - 코드브릿지엑스 지원으로 무료 제공',
|
||||
'free_quota' => 0,
|
||||
'free_quota_unit' => '월정액',
|
||||
'additional_unit' => 0,
|
||||
'additional_unit_label' => '-',
|
||||
'additional_price' => 0,
|
||||
'is_active' => true,
|
||||
'sort_order' => 4,
|
||||
],
|
||||
[
|
||||
'service_type' => 'hometax_sales',
|
||||
'name' => '홈택스 매출',
|
||||
'description' => '월정액 33,000원(VAT포함) - 코드브릿지엑스 지원으로 무료 제공',
|
||||
'free_quota' => 0,
|
||||
'free_quota_unit' => '월정액',
|
||||
'additional_unit' => 0,
|
||||
'additional_unit_label' => '-',
|
||||
'additional_price' => 0,
|
||||
'is_active' => true,
|
||||
'sort_order' => 5,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
|
||||
@@ -191,83 +191,159 @@
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 사업자번호</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierBizno} readOnly />
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* 공급자 정보 */}
|
||||
<div className="bg-stone-50 rounded-lg p-4 border border-stone-200">
|
||||
<h4 className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">공급자 정보</h4>
|
||||
<div className="grid grid-cols-12 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">사업자번호</label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm bg-white outline-none" value={formData.supplierBizno} readOnly />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">상호</label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm bg-white outline-none" value={formData.supplierName} readOnly />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">대표자</label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm bg-white outline-none" value={formData.supplierCeo} readOnly />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">주소</label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm bg-white outline-none" value={formData.supplierAddr} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 상호</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierName} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 대표자명</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierCeo} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 주소</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierAddr} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급받는자 사업자번호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientBizno} onChange={(e) => setFormData({ ...formData, recipientBizno: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급받는자 상호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientName} onChange={(e) => setFormData({ ...formData, recipientName: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급받는자 대표자명</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientCeo} onChange={(e) => setFormData({ ...formData, recipientCeo: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급받는자 주소 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientAddr} onChange={(e) => setFormData({ ...formData, recipientAddr: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급받는자 이메일 <span className="text-red-500">*</span></label>
|
||||
<input type="email" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientEmail} onChange={(e) => setFormData({ ...formData, recipientEmail: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급일자</label>
|
||||
<input type="date" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.supplyDate} onChange={(e) => setFormData({ ...formData, supplyDate: e.target.value })} required />
|
||||
</div>
|
||||
|
||||
{/* 공급받는자 정보 */}
|
||||
<div className="bg-blue-50/50 rounded-lg p-4 border border-blue-100">
|
||||
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wide mb-3">공급받는자 정보</h4>
|
||||
<div className="grid grid-cols-12 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">사업자번호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientBizno} onChange={(e) => setFormData({ ...formData, recipientBizno: e.target.value })} required />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">상호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientName} onChange={(e) => setFormData({ ...formData, recipientName: e.target.value })} required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">대표자</label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientCeo} onChange={(e) => setFormData({ ...formData, recipientCeo: e.target.value })} />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">주소 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientAddr} onChange={(e) => setFormData({ ...formData, recipientAddr: e.target.value })} required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">담당자</label>
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientContact} onChange={(e) => setFormData({ ...formData, recipientContact: e.target.value })} />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">이메일 <span className="text-red-500">*</span></label>
|
||||
<input type="email" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientEmail} onChange={(e) => setFormData({ ...formData, recipientEmail: e.target.value })} required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">작성일자</label>
|
||||
<input type="date" className="w-full rounded border-stone-200 border px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.supplyDate} onChange={(e) => setFormData({ ...formData, supplyDate: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<label className="block text-sm font-medium text-stone-700">품목 정보</label>
|
||||
<button type="button" onClick={handleAddItem} className="text-sm text-blue-600 hover:text-blue-700 font-medium">+ 품목 추가</button>
|
||||
<button type="button" onClick={handleAddItem} className="px-3 py-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
품목 추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{formData.items.map((item, index) => (
|
||||
<div key={index} className="grid grid-cols-12 gap-2 items-end p-3 bg-stone-50 rounded-lg">
|
||||
<div className="col-span-4">
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="품목명" value={item.name} onChange={(e) => handleItemChange(index, 'name', e.target.value)} required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input type="number" className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="수량" value={item.qty} onChange={(e) => handleItemChange(index, 'qty', parseFloat(e.target.value) || 0)} min="0" step="0.01" required />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<input type="number" className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="단가" value={item.unitPrice} onChange={(e) => handleItemChange(index, 'unitPrice', parseFloat(e.target.value) || 0)} min="0" required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<select className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={item.vatType} onChange={(e) => handleItemChange(index, 'vatType', e.target.value)}>
|
||||
<option value="vat">과세</option>
|
||||
<option value="zero">영세</option>
|
||||
<option value="exempt">면세</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
{formData.items.length > 1 && (
|
||||
<button type="button" onClick={() => setFormData({ ...formData, items: formData.items.filter((_, i) => i !== index) })} className="w-full p-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg border border-red-200">
|
||||
<svg className="w-4 h-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border border-stone-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead className="bg-stone-100 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
|
||||
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[45px]">수량</th>
|
||||
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[70px]">단가</th>
|
||||
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[75px]">공급가액</th>
|
||||
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[65px]">세액</th>
|
||||
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[75px]">금액</th>
|
||||
<th className="px-1 py-2.5 text-center font-medium text-stone-700 w-[50px]">과세</th>
|
||||
<th className="px-1 py-2.5 text-center font-medium text-stone-700 w-[28px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{formData.items.map((item, index) => {
|
||||
const supplyAmt = item.qty * item.unitPrice;
|
||||
const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0;
|
||||
const total = supplyAmt + vat;
|
||||
|
||||
// 콤마 형식으로 숫자 표시
|
||||
const formatWithComma = (num) => num ? num.toLocaleString() : '';
|
||||
// 콤마 제거하고 숫자로 변환
|
||||
const parseNumber = (str) => parseFloat(str.replace(/,/g, '')) || 0;
|
||||
|
||||
return (
|
||||
<tr key={index} className="hover:bg-stone-50">
|
||||
<td className="px-2 py-2">
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="품목명 입력" value={item.name} onChange={(e) => handleItemChange(index, 'name', e.target.value)} required />
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2 py-1.5 text-sm text-right focus:ring-2 focus:ring-blue-500 outline-none" value={formatWithComma(item.qty)} onChange={(e) => handleItemChange(index, 'qty', parseNumber(e.target.value))} required />
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input type="text" className="w-full rounded border-stone-200 border px-2 py-1.5 text-sm text-right focus:ring-2 focus:ring-blue-500 outline-none" value={formatWithComma(item.unitPrice)} onChange={(e) => handleItemChange(index, 'unitPrice', parseNumber(e.target.value))} required />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-medium text-stone-800 bg-stone-50">
|
||||
{supplyAmt.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-medium text-blue-600 bg-stone-50">
|
||||
{vat.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-stone-900 bg-blue-50">
|
||||
{total.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select className="w-full rounded border-stone-200 border px-1 py-1.5 text-sm text-center focus:ring-2 focus:ring-blue-500 outline-none" value={item.vatType} onChange={(e) => handleItemChange(index, 'vatType', e.target.value)}>
|
||||
<option value="vat">과세</option>
|
||||
<option value="zero">영세</option>
|
||||
<option value="exempt">면세</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{formData.items.length > 1 && (
|
||||
<button type="button" onClick={() => setFormData({ ...formData, items: formData.items.filter((_, i) => i !== index) })} className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded transition-colors" title="삭제">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="bg-stone-100 border-t-2 border-stone-300">
|
||||
<tr>
|
||||
<td colSpan="3" className="px-3 py-3 text-right font-bold text-stone-700">합계</td>
|
||||
<td className="px-3 py-3 text-right font-bold text-stone-800">
|
||||
{formData.items.reduce((sum, item) => sum + (item.qty * item.unitPrice), 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-bold text-blue-600">
|
||||
{formData.items.reduce((sum, item) => {
|
||||
const supplyAmt = item.qty * item.unitPrice;
|
||||
return sum + (item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0);
|
||||
}, 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-bold text-blue-700 bg-blue-100">
|
||||
{formData.items.reduce((sum, item) => {
|
||||
const supplyAmt = item.qty * item.unitPrice;
|
||||
const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0;
|
||||
return sum + supplyAmt + vat;
|
||||
}, 0).toLocaleString()}
|
||||
</td>
|
||||
<td colSpan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,7 +373,7 @@
|
||||
};
|
||||
|
||||
// InvoiceList Component
|
||||
const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount }) => {
|
||||
const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount, sortColumn, sortDirection, onSort }) => {
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
||||
const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR');
|
||||
|
||||
@@ -312,6 +388,39 @@
|
||||
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>{config.label}</span>;
|
||||
};
|
||||
|
||||
// 정렬 아이콘
|
||||
const SortIcon = ({ column }) => {
|
||||
if (sortColumn !== column) {
|
||||
return (
|
||||
<svg className="w-4 h-4 ml-1 text-stone-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return sortDirection === 'asc' ? (
|
||||
<svg className="w-4 h-4 ml-1 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 ml-1 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// 정렬 가능한 헤더 컴포넌트
|
||||
const SortableHeader = ({ column, children, className = '' }) => (
|
||||
<th
|
||||
className={`px-6 py-4 bg-stone-50 cursor-pointer hover:bg-stone-100 transition-colors select-none ${className}`}
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
<SortIcon column={column} />
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-100">
|
||||
@@ -363,8 +472,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-6 py-4 bg-stone-50">발행번호</th>
|
||||
<th className="px-6 py-4 bg-stone-50">공급받는자</th>
|
||||
<th className="px-6 py-4 bg-stone-50">공급일자</th>
|
||||
<SortableHeader column="recipientName">공급받는자</SortableHeader>
|
||||
<SortableHeader column="supplyDate">작성일자</SortableHeader>
|
||||
<th className="px-6 py-4 bg-stone-50">전송일자</th>
|
||||
<th className="px-6 py-4 bg-stone-50">공급가액</th>
|
||||
<th className="px-6 py-4 bg-stone-50">부가세</th>
|
||||
<th className="px-6 py-4 bg-stone-50">합계</th>
|
||||
@@ -374,13 +484,14 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{invoices.length === 0 ? (
|
||||
<tr><td colSpan="8" className="px-6 py-8 text-center text-stone-400">해당 기간에 발행된 세금계산서가 없습니다.</td></tr>
|
||||
<tr><td colSpan="9" className="px-6 py-8 text-center text-stone-400">해당 기간에 발행된 세금계산서가 없습니다.</td></tr>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="hover:bg-stone-50 transition-colors cursor-pointer" onClick={() => onViewDetail(invoice)}>
|
||||
<td className="px-6 py-4 font-medium text-stone-900">{invoice.issueKey || invoice.id}</td>
|
||||
<td className="px-6 py-4">{invoice.recipientName}</td>
|
||||
<td className="px-6 py-4">{formatDate(invoice.supplyDate)}</td>
|
||||
<td className="px-6 py-4">{invoice.sentAt ? formatDate(invoice.sentAt) : <span className="text-stone-300">-</span>}</td>
|
||||
<td className="px-6 py-4">{formatCurrency(invoice.totalSupplyAmt)}</td>
|
||||
<td className="px-6 py-4">{formatCurrency(invoice.totalVat)}</td>
|
||||
<td className="px-6 py-4 font-bold text-stone-900">{formatCurrency(invoice.total)}</td>
|
||||
@@ -436,9 +547,15 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
<div className="text-sm text-stone-500">{invoice.recipientBizno}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-1 block">공급일자</label>
|
||||
<div className="font-medium text-stone-900">{formatDate(invoice.supplyDate)}</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-1 block">작성일자</label>
|
||||
<div className="font-medium text-stone-900">{formatDate(invoice.supplyDate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-1 block">전송일자</label>
|
||||
<div className="font-medium text-stone-900">{invoice.sentAt ? formatDate(invoice.sentAt) : <span className="text-stone-400">미전송</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-2 block">품목 내역</label>
|
||||
@@ -555,6 +672,10 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
const [dateFrom, setDateFrom] = useState(currentMonth.from);
|
||||
const [dateTo, setDateTo] = useState(currentMonth.to);
|
||||
|
||||
// 정렬 상태 (기본: 작성일자 내림차순)
|
||||
const [sortColumn, setSortColumn] = useState('supplyDate');
|
||||
const [sortDirection, setSortDirection] = useState('desc');
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
}, []);
|
||||
@@ -648,6 +769,18 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬 핸들러
|
||||
const handleSort = (column) => {
|
||||
if (sortColumn === column) {
|
||||
// 같은 컬럼 클릭 시 정렬 방향 토글
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
// 다른 컬럼 클릭 시 해당 컬럼으로 변경, 내림차순 기본
|
||||
setSortColumn(column);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 필터 적용된 송장 목록
|
||||
const filteredInvoices = invoices.filter(invoice => {
|
||||
const supplyDate = invoice.supplyDate;
|
||||
@@ -655,6 +788,27 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
return supplyDate >= dateFrom && supplyDate <= dateTo;
|
||||
});
|
||||
|
||||
// 정렬 적용
|
||||
const sortedInvoices = [...filteredInvoices].sort((a, b) => {
|
||||
let aVal = a[sortColumn];
|
||||
let bVal = b[sortColumn];
|
||||
|
||||
// null/undefined 처리
|
||||
if (aVal == null) aVal = '';
|
||||
if (bVal == null) bVal = '';
|
||||
|
||||
// 문자열 비교
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
const comparison = aVal.localeCompare(bVal, 'ko-KR');
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
}
|
||||
|
||||
// 숫자 비교
|
||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 이번 달 버튼
|
||||
const handleThisMonth = () => {
|
||||
const dates = getMonthDates(0);
|
||||
@@ -723,7 +877,7 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
|
||||
{/* Invoice List */}
|
||||
<InvoiceList
|
||||
invoices={filteredInvoices}
|
||||
invoices={sortedInvoices}
|
||||
onViewDetail={setSelectedInvoice}
|
||||
onCheckStatus={handleCheckStatus}
|
||||
onDelete={handleDelete}
|
||||
@@ -734,6 +888,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
onThisMonth={handleThisMonth}
|
||||
onLastMonth={handleLastMonth}
|
||||
totalCount={invoices.length}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
{/* API Logs */}
|
||||
|
||||
@@ -8,28 +8,21 @@
|
||||
|
||||
// R&D Labs 그룹 메뉴 찾기
|
||||
$labsGroup = $menus->first();
|
||||
if (!$labsGroup || !isset($labsGroup->menuChildren)) {
|
||||
if (!$labsGroup || !isset($labsGroup->menuChildren) || $labsGroup->menuChildren->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 자식 메뉴들을 탭별로 분류
|
||||
$sMenus = collect();
|
||||
$aMenus = collect();
|
||||
$mMenus = collect();
|
||||
// 자식 메뉴를 S(Strategy)와 A(AI/Automation)로 분리
|
||||
$labsChildMenus = $labsGroup->menuChildren;
|
||||
|
||||
foreach ($labsGroup->menuChildren as $menu) {
|
||||
$tab = $menu->getMeta('tab') ?? 'S';
|
||||
match ($tab) {
|
||||
'S' => $sMenus->push($menu),
|
||||
'A' => $aMenus->push($menu),
|
||||
'M' => $mMenus->push($menu),
|
||||
default => $sMenus->push($menu),
|
||||
};
|
||||
}
|
||||
// 메뉴 코드로 분리 (S.로 시작하면 Strategy, A.로 시작하면 AI)
|
||||
$sMenus = $labsChildMenus->filter(fn($m) => str_starts_with($m->code ?? '', 'S.'));
|
||||
$aMenus = $labsChildMenus->filter(fn($m) => str_starts_with($m->code ?? '', 'A.'));
|
||||
|
||||
// 모든 탭이 비어있으면 렌더링하지 않음
|
||||
if ($sMenus->isEmpty() && $aMenus->isEmpty() && $mMenus->isEmpty()) {
|
||||
return;
|
||||
// 만약 코드로 분리가 안 되면 모든 메뉴를 sMenus에 넣음
|
||||
if ($sMenus->isEmpty() && $aMenus->isEmpty()) {
|
||||
$sMenus = $labsChildMenus;
|
||||
$aMenus = collect([]);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@@ -37,7 +30,7 @@
|
||||
<li class="lab-menu-container pt-4 pb-1 border-t border-gray-200 mt-2" id="lab-menu-container">
|
||||
<!-- 확장 상태: 헤더 + 탭 + 메뉴 -->
|
||||
<div class="lab-expanded-view">
|
||||
<button onclick="toggleGroup('lab-group'); scrollSidebarToBottom();" class="sidebar-group-header lab-group-header w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-700 uppercase tracking-wider rounded">
|
||||
<button onclick="toggleGroup('lab-group'); scrollSidebarToBottom();" class="sidebar-group-header lab-group-header w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-gray-700 rounded">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
@@ -49,7 +42,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 탭 + 메뉴 콘텐츠 -->
|
||||
<!-- 메뉴 콘텐츠 -->
|
||||
<div id="lab-group" class="mt-2">
|
||||
<!-- S | A 탭 버튼 -->
|
||||
<div class="lab-tabs flex mx-2 mb-2 bg-gray-100 rounded-lg p-1">
|
||||
@@ -73,7 +66,7 @@
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||||
title="{{ $menu->name }}">
|
||||
<x-sidebar.menu-icon :icon="$menu->icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="font-medium sidebar-text">{{ $menu->name }}</span>
|
||||
<span class="sidebar-text">{{ $menu->name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
@@ -96,7 +89,6 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,17 +112,7 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:
|
||||
<span class="text-xs font-bold text-amber-800 uppercase tracking-wider">R&D Labs</span>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="flex p-2 bg-gray-50 border-b border-gray-100">
|
||||
<button type="button" onclick="switchLabFlyoutTab('s')" id="lab-flyout-tab-s" class="lab-flyout-tab active flex-1 py-1.5 text-xs font-bold rounded transition-all text-blue-600">
|
||||
S
|
||||
</button>
|
||||
<button type="button" onclick="switchLabFlyoutTab('a')" id="lab-flyout-tab-a" class="lab-flyout-tab flex-1 py-1.5 text-xs font-bold rounded transition-all text-purple-600">
|
||||
A
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 패널들 -->
|
||||
<!-- 메뉴 패널 -->
|
||||
<div class="p-2 max-h-64 overflow-y-auto">
|
||||
<!-- S. Strategy -->
|
||||
<ul id="lab-flyout-panel-s" class="lab-flyout-panel space-y-0.5">
|
||||
@@ -163,7 +145,6 @@ class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:tex
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<li class="pt-4 pb-1 border-t border-gray-200 mt-2">
|
||||
<button
|
||||
onclick="toggleMenuGroup('{{ $groupId }}')"
|
||||
class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-600 uppercase tracking-wider hover:bg-gray-50 rounded"
|
||||
class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 rounded"
|
||||
style="padding-left: {{ $paddingLeft }}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -60,7 +60,7 @@ class="sidebar-subgroup-header w-full flex items-center justify-between px-3 py-
|
||||
@if($menu->icon)
|
||||
<x-sidebar.menu-icon :icon="$menu->icon" class="w-4 h-4" />
|
||||
@endif
|
||||
<span class="font-medium sidebar-text">{{ $menu->name }}</span>
|
||||
<span class="font-semibold sidebar-text">{{ $menu->name }}</span>
|
||||
</span>
|
||||
<svg
|
||||
id="{{ $groupId }}-icon"
|
||||
|
||||
@@ -33,7 +33,7 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm {{ $activeClass }}"
|
||||
@if($menu->icon)
|
||||
<x-sidebar.menu-icon :icon="$menu->icon" />
|
||||
@endif
|
||||
<span class="font-medium sidebar-text">{{ $menu->name }}</span>
|
||||
<span class="sidebar-text">{{ $menu->name }}</span>
|
||||
@if($menu->is_external)
|
||||
<x-sidebar.menu-icon icon="external-link" class="w-3 h-3 opacity-50" />
|
||||
@endif
|
||||
|
||||
240
resources/views/credit/usage/index.blade.php
Normal file
240
resources/views/credit/usage/index.blade.php
Normal file
@@ -0,0 +1,240 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '조회회수 집계')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">신용평가 조회회수 집계</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
@if($isHQ)
|
||||
전체 테넌트의 신용평가 조회 현황을 확인합니다
|
||||
@else
|
||||
월별/기간별 신용평가 조회 현황을 확인합니다
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-4">
|
||||
<!-- 조회 유형 -->
|
||||
<div class="min-w-[120px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">조회 유형</label>
|
||||
<select name="view_type" onchange="toggleViewType(this.value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="monthly" {{ $filters['view_type'] === 'monthly' ? 'selected' : '' }}>월별</option>
|
||||
<option value="yearly" {{ $filters['view_type'] === 'yearly' ? 'selected' : '' }}>연간</option>
|
||||
<option value="custom" {{ $filters['view_type'] === 'custom' ? 'selected' : '' }}>기간 지정</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 연도 선택 -->
|
||||
<div id="year-filter" class="min-w-[120px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">연도</label>
|
||||
<select name="year" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@for($y = date('Y'); $y >= date('Y') - 2; $y--)
|
||||
<option value="{{ $y }}" {{ $filters['year'] == $y ? 'selected' : '' }}>{{ $y }}년</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 월 선택 (월별 조회 시) -->
|
||||
<div id="month-filter" class="min-w-[120px] {{ $filters['view_type'] !== 'monthly' ? 'hidden' : '' }}">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">월</label>
|
||||
<select name="month" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@for($m = 1; $m <= 12; $m++)
|
||||
<option value="{{ str_pad($m, 2, '0', STR_PAD_LEFT) }}" {{ $filters['month'] == str_pad($m, 2, '0', STR_PAD_LEFT) ? 'selected' : '' }}>
|
||||
{{ $m }}월
|
||||
</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 기간 지정 (기간 지정 시) -->
|
||||
<div id="custom-date-filter" class="{{ $filters['view_type'] !== 'custom' ? 'hidden' : '' }}">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">기간</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="date" name="start_date" value="{{ $filters['start_date'] }}"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<span class="text-gray-500">~</span>
|
||||
<input type="date" name="end_date" value="{{ $filters['end_date'] }}"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($isHQ && $tenants->isNotEmpty())
|
||||
<!-- 테넌트 필터 (본사만) -->
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트</label>
|
||||
<select name="tenant_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체</option>
|
||||
@foreach($tenants as $tenant)
|
||||
<option value="{{ $tenant->id }}" {{ $filters['tenant_id'] == $tenant->id ? 'selected' : '' }}>
|
||||
{{ $tenant->company_name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
조회
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">총 조회 건수</p>
|
||||
<p class="text-2xl font-bold text-gray-800">{{ number_format($usageData['total_count']) }}건</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">유료 조회 건수</p>
|
||||
<p class="text-2xl font-bold text-orange-600">
|
||||
{{ number_format(max(0, $usageData['total_count'] - (count($usageData['details']) * $policy['free_quota']))) }}건
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">예상 청구 금액</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ number_format($usageData['total_fee']) }}원</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 테이블 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800">
|
||||
@if($isHQ)
|
||||
테넌트별 조회 현황
|
||||
@else
|
||||
월별 조회 현황
|
||||
@endif
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@if(count($usageData['details']) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
@if($isHQ)
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">테넌트</th>
|
||||
@endif
|
||||
@if($filters['view_type'] === 'yearly' || !$isHQ)
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">기간</th>
|
||||
@endif
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">총 조회</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">무료 ({{ $policy['free_quota'] }}건)</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">유료</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">요금</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($usageData['details'] as $row)
|
||||
<tr class="hover:bg-gray-50">
|
||||
@if($isHQ)
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $row['tenant_name'] }}</div>
|
||||
@if(isset($row['tenant_code']))
|
||||
<div class="text-xs text-gray-500">{{ $row['tenant_code'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
@if($filters['view_type'] === 'yearly' || !$isHQ)
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $row['month'] ?? '-' }}
|
||||
</td>
|
||||
@endif
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
|
||||
{{ number_format($row['count']) }}건
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-green-600">
|
||||
{{ number_format($row['free_count']) }}건
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right {{ $row['paid_count'] > 0 ? 'text-orange-600 font-medium' : 'text-gray-500' }}">
|
||||
{{ number_format($row['paid_count']) }}건
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right {{ $row['fee'] > 0 ? 'text-blue-600 font-medium' : 'text-gray-500' }}">
|
||||
{{ number_format($row['fee']) }}원
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot class="bg-gray-50">
|
||||
<tr class="font-semibold">
|
||||
<td class="px-6 py-4 text-sm text-gray-900" colspan="{{ $isHQ ? ($filters['view_type'] === 'yearly' ? 2 : 1) : 1 }}">
|
||||
합계
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-right text-gray-900">{{ number_format($usageData['total_count']) }}건</td>
|
||||
<td class="px-6 py-4 text-sm text-right text-green-600">
|
||||
{{ number_format(array_sum(array_column($usageData['details'], 'free_count'))) }}건
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-right text-orange-600">
|
||||
{{ number_format(array_sum(array_column($usageData['details'], 'paid_count'))) }}건
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-right text-blue-600">{{ number_format($usageData['total_fee']) }}원</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-6 py-12 text-center text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p>조회 내역이 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function toggleViewType(value) {
|
||||
const monthFilter = document.getElementById('month-filter');
|
||||
const customDateFilter = document.getElementById('custom-date-filter');
|
||||
|
||||
monthFilter.classList.add('hidden');
|
||||
customDateFilter.classList.add('hidden');
|
||||
|
||||
if (value === 'monthly') {
|
||||
monthFilter.classList.remove('hidden');
|
||||
} else if (value === 'custom') {
|
||||
customDateFilter.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -120,7 +120,7 @@ function SalesCommissionManagement() {
|
||||
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setCommissions(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
|
||||
|
||||
const handleDownload = () => {
|
||||
const rows = [['영업수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '영업담당', '고객사', '프로젝트', '매출액', '수수료율', '수수료', '상태'],
|
||||
const rows = [['영업수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '영업파트너', '고객사', '프로젝트', '매출액', '수수료율', '수수료', '상태'],
|
||||
...filteredCommissions.map(item => [item.date, item.salesperson, item.customer, item.project, item.salesAmount, `${item.rate}%`, item.commission, item.status === 'paid' ? '지급완료' : '지급예정'])];
|
||||
const csvContent = rows.map(row => row.join(',')).join('\n');
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
@@ -188,7 +188,7 @@ function SalesCommissionManagement() {
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">영업담당</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">영업파트너</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객사</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">프로젝트</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">매출액</th>
|
||||
@@ -231,7 +231,7 @@ function SalesCommissionManagement() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">영업담당</label><select value={formData.salesperson} onChange={(e) => setFormData(prev => ({ ...prev, salesperson: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{salespersons.map(p => <option key={p} value={p}>{p}</option>)}</select></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">영업파트너</label><select value={formData.salesperson} onChange={(e) => setFormData(prev => ({ ...prev, salesperson: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{salespersons.map(p => <option key={p} value={p}>{p}</option>)}</select></div>
|
||||
</div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">프로젝트</label><input type="text" value={formData.project} onChange={(e) => setFormData(prev => ({ ...prev, project: e.target.value }))} placeholder="프로젝트명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
|
||||
|
||||
376
resources/views/finance/sales-commission/index.blade.php
Normal file
376
resources/views/finance/sales-commission/index.blade.php
Normal file
@@ -0,0 +1,376 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '영업수수료정산')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
{{-- 페이지 헤더 --}}
|
||||
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">영업수수료정산</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $year }}년 {{ $month }}월 지급예정</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button"
|
||||
onclick="openPaymentModal()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
|
||||
<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 4v16m8-8H4"/>
|
||||
</svg>
|
||||
입금 등록
|
||||
</button>
|
||||
<a href="{{ route('finance.sales-commissions.export', ['year' => $year, 'month' => $month]) }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
엑셀 다운로드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 --}}
|
||||
<div id="stats-container">
|
||||
@include('finance.sales-commission.partials.stats-cards', ['stats' => $stats, 'year' => $year, 'month' => $month])
|
||||
</div>
|
||||
|
||||
{{-- 필터 섹션 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form id="filter-form" method="GET" action="{{ route('finance.sales-commissions.index') }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||
{{-- 년/월 선택 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">년도</label>
|
||||
<select name="year" class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
@for ($y = now()->year - 2; $y <= now()->year + 1; $y++)
|
||||
<option value="{{ $y }}" {{ $year == $y ? 'selected' : '' }}>{{ $y }}년</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">월</label>
|
||||
<select name="month" class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
@for ($m = 1; $m <= 12; $m++)
|
||||
<option value="{{ $m }}" {{ $month == $m ? 'selected' : '' }}>{{ $m }}월</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 상태 필터 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select name="status" class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
<option value="">전체</option>
|
||||
<option value="pending" {{ ($filters['status'] ?? '') == 'pending' ? 'selected' : '' }}>대기</option>
|
||||
<option value="approved" {{ ($filters['status'] ?? '') == 'approved' ? 'selected' : '' }}>승인</option>
|
||||
<option value="paid" {{ ($filters['status'] ?? '') == 'paid' ? 'selected' : '' }}>지급완료</option>
|
||||
<option value="cancelled" {{ ($filters['status'] ?? '') == 'cancelled' ? 'selected' : '' }}>취소</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 입금구분 필터 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">입금구분</label>
|
||||
<select name="payment_type" class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
<option value="">전체</option>
|
||||
<option value="deposit" {{ ($filters['payment_type'] ?? '') == 'deposit' ? 'selected' : '' }}>계약금</option>
|
||||
<option value="balance" {{ ($filters['payment_type'] ?? '') == 'balance' ? 'selected' : '' }}>잔금</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 영업파트너 필터 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">영업파트너</label>
|
||||
<select name="partner_id" class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
<option value="">전체</option>
|
||||
@foreach ($partners as $partner)
|
||||
<option value="{{ $partner->id }}" {{ ($filters['partner_id'] ?? '') == $partner->id ? 'selected' : '' }}>
|
||||
{{ $partner->user->name ?? $partner->partner_code }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 버튼 --}}
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
|
||||
조회
|
||||
</button>
|
||||
<a href="{{ route('finance.sales-commissions.index') }}"
|
||||
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors">
|
||||
초기화
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 일괄 처리 버튼 --}}
|
||||
<div class="flex items-center gap-2 mb-4" id="bulk-actions" style="display: none;">
|
||||
<span class="text-sm text-gray-600"><span id="selected-count">0</span>건 선택</span>
|
||||
<button type="button" onclick="bulkApprove()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors">
|
||||
일괄 승인
|
||||
</button>
|
||||
<button type="button" onclick="bulkMarkPaid()" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors">
|
||||
일괄 지급완료
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 정산 테이블 --}}
|
||||
<div id="table-container">
|
||||
@include('finance.sales-commission.partials.commission-table', ['commissions' => $commissions])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 입금 등록 모달 --}}
|
||||
<div id="payment-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">입금 등록</h3>
|
||||
<button type="button" onclick="closePaymentModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
<div id="payment-form-container" class="p-6">
|
||||
@include('finance.sales-commission.partials.payment-form', ['management' => null, 'pendingTenants' => $pendingTenants])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 상세 모달 --}}
|
||||
<div id="detail-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div id="detail-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 선택된 정산 ID 배열
|
||||
let selectedIds = [];
|
||||
|
||||
// 체크박스 변경 시
|
||||
function updateSelection() {
|
||||
selectedIds = Array.from(document.querySelectorAll('.commission-checkbox:checked'))
|
||||
.map(cb => parseInt(cb.value));
|
||||
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
bulkActions.style.display = 'flex';
|
||||
selectedCount.textContent = selectedIds.length;
|
||||
} else {
|
||||
bulkActions.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 선택/해제
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.commission-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
// 입금 등록 모달 열기
|
||||
function openPaymentModal() {
|
||||
document.getElementById('payment-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 입금 등록 모달 닫기
|
||||
function closePaymentModal() {
|
||||
document.getElementById('payment-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 입금 등록 제출
|
||||
function submitPayment() {
|
||||
const form = document.getElementById('payment-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch('{{ route("finance.sales-commissions.payment") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
closePaymentModal();
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '오류가 발생했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 상세 모달 열기
|
||||
function openDetailModal(commissionId) {
|
||||
fetch('{{ url("finance/sales-commissions") }}/' + commissionId + '/detail')
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('detail-modal-content').innerHTML = html;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// 상세 모달 닫기
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detail-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 승인 처리
|
||||
function approveCommission(id) {
|
||||
if (!confirm('승인하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ url("finance/sales-commissions") }}/' + id + '/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 일괄 승인
|
||||
function bulkApprove() {
|
||||
if (selectedIds.length === 0) {
|
||||
alert('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(selectedIds.length + '건을 승인하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ route("finance.sales-commissions.bulk-approve") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: selectedIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 지급완료 처리
|
||||
function markPaidCommission(id) {
|
||||
const bankReference = prompt('이체 참조번호를 입력하세요 (선택사항)');
|
||||
if (bankReference === null) return;
|
||||
|
||||
fetch('{{ url("finance/sales-commissions") }}/' + id + '/mark-paid', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ bank_reference: bankReference })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 일괄 지급완료
|
||||
function bulkMarkPaid() {
|
||||
if (selectedIds.length === 0) {
|
||||
alert('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const bankReference = prompt('이체 참조번호를 입력하세요 (선택사항)');
|
||||
if (bankReference === null) return;
|
||||
|
||||
if (!confirm(selectedIds.length + '건을 지급완료 처리하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ route("finance.sales-commissions.bulk-mark-paid") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: selectedIds, bank_reference: bankReference })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 취소 처리
|
||||
function cancelCommission(id) {
|
||||
if (!confirm('취소하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ url("finance/sales-commissions") }}/' + id + '/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 테넌트 선택 시 금액 자동 계산
|
||||
function onTenantSelect(managementId) {
|
||||
if (!managementId) return;
|
||||
|
||||
// HTMX로 폼 업데이트
|
||||
htmx.ajax('GET', '{{ route("finance.sales-commissions.payment-form") }}?management_id=' + managementId, {
|
||||
target: '#payment-form-container'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,138 @@
|
||||
{{-- 정산 테이블 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="w-12 px-4 py-3">
|
||||
<input type="checkbox" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500">
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">테넌트</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">입금구분</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">입금액</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">입금일</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">영업파트너</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">파트너수당</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">매니저</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">매니저수당</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">지급예정일</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse ($commissions as $commission)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
@if (in_array($commission->status, ['pending', 'approved']))
|
||||
<input type="checkbox"
|
||||
value="{{ $commission->id }}"
|
||||
onchange="updateSelection()"
|
||||
class="commission-checkbox rounded border-gray-300 text-emerald-600 focus:ring-emerald-500">
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $commission->tenant->name ?? $commission->tenant->company_name ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-500">ID: {{ $commission->tenant_id }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{{ $commission->payment_type === 'deposit' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800' }}">
|
||||
{{ $commission->payment_type_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm text-gray-900">
|
||||
{{ number_format($commission->payment_amount) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-500">
|
||||
{{ $commission->payment_date->format('Y-m-d') }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm text-gray-900">{{ $commission->partner?->user?->name ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $commission->partner_rate }}%</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm font-medium text-emerald-600">
|
||||
{{ number_format($commission->partner_commission) }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm text-gray-900">{{ $commission->manager?->name ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $commission->manager_rate }}%</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm font-medium text-blue-600">
|
||||
{{ number_format($commission->manager_commission) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-500">
|
||||
{{ $commission->scheduled_payment_date->format('Y-m-d') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@php
|
||||
$statusColors = [
|
||||
'pending' => 'bg-yellow-100 text-yellow-800',
|
||||
'approved' => 'bg-blue-100 text-blue-800',
|
||||
'paid' => 'bg-green-100 text-green-800',
|
||||
'cancelled' => 'bg-red-100 text-red-800',
|
||||
];
|
||||
@endphp
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$commission->status] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ $commission->status_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button type="button"
|
||||
onclick="openDetailModal({{ $commission->id }})"
|
||||
class="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="상세보기">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if ($commission->status === 'pending')
|
||||
<button type="button"
|
||||
onclick="approveCommission({{ $commission->id }})"
|
||||
class="p-1 text-blue-400 hover:text-blue-600"
|
||||
title="승인">
|
||||
<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>
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="cancelCommission({{ $commission->id }})"
|
||||
class="p-1 text-red-400 hover:text-red-600"
|
||||
title="취소">
|
||||
<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 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@elseif ($commission->status === 'approved')
|
||||
<button type="button"
|
||||
onclick="markPaidCommission({{ $commission->id }})"
|
||||
class="p-1 text-green-400 hover:text-green-600"
|
||||
title="지급완료">
|
||||
<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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="12" class="px-4 py-8 text-center text-gray-500">
|
||||
등록된 정산 내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if ($commissions->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200">
|
||||
{{ $commissions->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,196 @@
|
||||
{{-- 정산 상세 모달 --}}
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">정산 상세</h3>
|
||||
<button type="button" onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
|
||||
@if ($commission)
|
||||
<div class="p-6">
|
||||
{{-- 기본 정보 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">테넌트</h4>
|
||||
<p class="text-gray-900">{{ $commission->tenant->name ?? $commission->tenant->company_name ?? '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">상태</h4>
|
||||
@php
|
||||
$statusColors = [
|
||||
'pending' => 'bg-yellow-100 text-yellow-800',
|
||||
'approved' => 'bg-blue-100 text-blue-800',
|
||||
'paid' => 'bg-green-100 text-green-800',
|
||||
'cancelled' => 'bg-red-100 text-red-800',
|
||||
];
|
||||
@endphp
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$commission->status] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ $commission->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">입금 구분</h4>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{{ $commission->payment_type === 'deposit' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800' }}">
|
||||
{{ $commission->payment_type_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">입금일</h4>
|
||||
<p class="text-gray-900">{{ $commission->payment_date->format('Y-m-d') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 금액 정보 --}}
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3">금액 정보</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">입금액</span>
|
||||
<span class="font-medium">{{ number_format($commission->payment_amount) }}원</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">수당 기준액 (가입비 50%)</span>
|
||||
<span class="font-medium">{{ number_format($commission->base_amount) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 수당 정보 --}}
|
||||
<div class="bg-emerald-50 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-emerald-800 mb-3">수당 정보</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<span class="text-gray-700">영업파트너</span>
|
||||
<span class="text-sm text-gray-500 ml-2">{{ $commission->partner?->user?->name ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-gray-500">{{ $commission->partner_rate }}%</span>
|
||||
<span class="ml-2 font-medium text-emerald-600">{{ number_format($commission->partner_commission) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<span class="text-gray-700">매니저</span>
|
||||
<span class="text-sm text-gray-500 ml-2">{{ $commission->manager?->name ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-gray-500">{{ $commission->manager_rate }}%</span>
|
||||
<span class="ml-2 font-medium text-blue-600">{{ number_format($commission->manager_commission) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-emerald-200 pt-2 mt-2 flex justify-between">
|
||||
<span class="font-medium text-gray-700">총 수당</span>
|
||||
<span class="font-bold text-emerald-700">{{ number_format($commission->total_commission) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 지급 일정 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">지급예정일</h4>
|
||||
<p class="text-gray-900">{{ $commission->scheduled_payment_date->format('Y-m-d') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">실제지급일</h4>
|
||||
<p class="text-gray-900">{{ $commission->actual_payment_date?->format('Y-m-d') ?? '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 승인 정보 --}}
|
||||
@if ($commission->approved_at)
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">승인자</h4>
|
||||
<p class="text-gray-900">{{ $commission->approver?->name ?? '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">승인일시</h4>
|
||||
<p class="text-gray-900">{{ $commission->approved_at->format('Y-m-d H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 이체 참조번호 --}}
|
||||
@if ($commission->bank_reference)
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">이체 참조번호</h4>
|
||||
<p class="text-gray-900">{{ $commission->bank_reference }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 메모 --}}
|
||||
@if ($commission->notes)
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">메모</h4>
|
||||
<p class="text-gray-900">{{ $commission->notes }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 상품별 상세 내역 --}}
|
||||
@if ($commission->details->count() > 0)
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3">상품별 수당 내역</h4>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">상품</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500">가입비</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500">파트너수당</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500">매니저수당</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($commission->details as $detail)
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900">{{ $detail->contractProduct?->product?->name ?? '-' }}</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900">{{ number_format($detail->registration_fee) }}원</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-emerald-600">{{ number_format($detail->partner_commission) }}원</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-blue-600">{{ number_format($detail->manager_commission) }}원</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 액션 버튼 --}}
|
||||
<div class="flex justify-end gap-2">
|
||||
@if ($commission->status === 'pending')
|
||||
<button type="button"
|
||||
onclick="approveCommission({{ $commission->id }})"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
승인
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="cancelCommission({{ $commission->id }})"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
@elseif ($commission->status === 'approved')
|
||||
<button type="button"
|
||||
onclick="markPaidCommission({{ $commission->id }})"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||
지급완료
|
||||
</button>
|
||||
@endif
|
||||
<button type="button"
|
||||
onclick="closeDetailModal()"
|
||||
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
정산 정보를 찾을 수 없습니다.
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,185 @@
|
||||
{{-- 입금 등록 폼 --}}
|
||||
<form id="payment-form" onsubmit="event.preventDefault(); submitPayment();">
|
||||
@csrf
|
||||
|
||||
{{-- 테넌트 선택 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트 선택 <span class="text-red-500">*</span></label>
|
||||
@if ($management)
|
||||
<input type="hidden" name="management_id" value="{{ $management->id }}">
|
||||
<div class="px-4 py-3 bg-gray-50 rounded-lg">
|
||||
<div class="font-medium text-gray-900">{{ $management->tenant->name ?? $management->tenant->company_name }}</div>
|
||||
<div class="text-sm text-gray-500">영업파트너: {{ $management->salesPartner?->user?->name ?? '-' }}</div>
|
||||
</div>
|
||||
@else
|
||||
<select name="management_id"
|
||||
onchange="onTenantSelect(this.value)"
|
||||
required
|
||||
class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
<option value="">-- 테넌트 선택 --</option>
|
||||
@foreach ($pendingTenants as $tenant)
|
||||
<option value="{{ $tenant->id }}">
|
||||
{{ $tenant->tenant->name ?? $tenant->tenant->company_name }}
|
||||
@if ($tenant->deposit_status === 'pending')
|
||||
(계약금 대기)
|
||||
@elseif ($tenant->balance_status === 'pending')
|
||||
(잔금 대기)
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($management)
|
||||
{{-- 계약 상품 정보 --}}
|
||||
@if ($management->contractProducts->count() > 0)
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">계약 상품</label>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">상품명</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500">가입비</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($management->contractProducts as $product)
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900">{{ $product->product?->name ?? '-' }}</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900">{{ number_format($product->registration_fee ?? 0) }}원</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot class="bg-gray-50">
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-sm font-medium text-gray-900">총 가입비</td>
|
||||
<td class="px-4 py-2 text-sm text-right font-bold text-emerald-600">
|
||||
{{ number_format($management->contractProducts->sum('registration_fee')) }}원
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 현재 입금 상태 --}}
|
||||
<div class="mb-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">계약금</span>
|
||||
<div class="font-medium {{ $management->deposit_status === 'paid' ? 'text-green-600' : 'text-yellow-600' }}">
|
||||
{{ $management->deposit_status === 'paid' ? '입금완료' : '대기' }}
|
||||
@if ($management->deposit_amount)
|
||||
({{ number_format($management->deposit_amount) }}원)
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">잔금</span>
|
||||
<div class="font-medium {{ $management->balance_status === 'paid' ? 'text-green-600' : 'text-yellow-600' }}">
|
||||
{{ $management->balance_status === 'paid' ? '입금완료' : '대기' }}
|
||||
@if ($management->balance_amount)
|
||||
({{ number_format($management->balance_amount) }}원)
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 입금 구분 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">입금 구분 <span class="text-red-500">*</span></label>
|
||||
<div class="flex gap-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" name="payment_type" value="deposit" required
|
||||
{{ ($management && $management->deposit_status === 'paid') ? 'disabled' : '' }}
|
||||
class="text-emerald-600 focus:ring-emerald-500">
|
||||
<span class="ml-2 text-sm text-gray-700">계약금</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" name="payment_type" value="balance" required
|
||||
{{ ($management && $management->balance_status === 'paid') ? 'disabled' : '' }}
|
||||
class="text-emerald-600 focus:ring-emerald-500">
|
||||
<span class="ml-2 text-sm text-gray-700">잔금</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 입금액 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">입금액 <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
<input type="number"
|
||||
name="payment_amount"
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
@if ($management)
|
||||
value="{{ $management->contractProducts->sum('registration_fee') / 2 }}"
|
||||
@endif
|
||||
class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500 pr-12">
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-500">원</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">총 가입비의 50%를 입금받습니다.</p>
|
||||
</div>
|
||||
|
||||
{{-- 입금일 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">입금일 <span class="text-red-500">*</span></label>
|
||||
<input type="date"
|
||||
name="payment_date"
|
||||
required
|
||||
value="{{ now()->format('Y-m-d') }}"
|
||||
class="w-full rounded-lg border-gray-300 focus:border-emerald-500 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
{{-- 수당 미리보기 --}}
|
||||
@if ($management && $management->salesPartner)
|
||||
@php
|
||||
$totalFee = $management->contractProducts->sum('registration_fee') ?: 0;
|
||||
$baseAmount = $totalFee / 2;
|
||||
$partnerRate = $management->salesPartner->commission_rate ?? 20;
|
||||
$managerRate = $management->salesPartner->manager_commission_rate ?? 5;
|
||||
$partnerCommission = $baseAmount * ($partnerRate / 100);
|
||||
$managerCommission = $management->manager_user_id ? $baseAmount * ($managerRate / 100) : 0;
|
||||
@endphp
|
||||
<div class="mb-4 p-4 bg-emerald-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-emerald-800 mb-2">수당 미리보기</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">기준액 (가입비의 50%)</span>
|
||||
<span class="font-medium">{{ number_format($baseAmount) }}원</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">영업파트너 수당 ({{ $partnerRate }}%)</span>
|
||||
<span class="font-medium text-emerald-600">{{ number_format($partnerCommission) }}원</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">매니저 수당 ({{ $managerRate }}%)</span>
|
||||
<span class="font-medium text-blue-600">{{ number_format($managerCommission) }}원</span>
|
||||
</div>
|
||||
<div class="border-t border-emerald-200 pt-1 mt-1 flex justify-between">
|
||||
<span class="font-medium text-gray-700">총 수당</span>
|
||||
<span class="font-bold text-emerald-700">{{ number_format($partnerCommission + $managerCommission) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 버튼 --}}
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button type="button"
|
||||
onclick="closePaymentModal()"
|
||||
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
|
||||
입금 등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,70 @@
|
||||
{{-- 통계 카드 --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
{{-- 지급 대기 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-yellow-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">지급 대기</p>
|
||||
<p class="text-xl font-bold text-yellow-600">{{ number_format($stats['pending']['partner_total'] + $stats['pending']['manager_total']) }}원</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-yellow-600" 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>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ $stats['pending']['count'] }}건</p>
|
||||
</div>
|
||||
|
||||
{{-- 승인 완료 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-blue-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">승인 완료</p>
|
||||
<p class="text-xl font-bold text-blue-600">{{ number_format($stats['approved']['partner_total'] + $stats['approved']['manager_total']) }}원</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" 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>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ $stats['approved']['count'] }}건</p>
|
||||
</div>
|
||||
|
||||
{{-- 지급 완료 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-green-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">지급 완료</p>
|
||||
<p class="text-xl font-bold text-green-600">{{ number_format($stats['paid']['partner_total'] + $stats['paid']['manager_total']) }}원</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ $stats['paid']['count'] }}건</p>
|
||||
</div>
|
||||
|
||||
{{-- 전체 합계 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-purple-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">{{ $year }}년 {{ $month }}월 총 수당</p>
|
||||
<p class="text-xl font-bold text-purple-600">{{ number_format($stats['total']['partner_commission'] + $stats['total']['manager_commission']) }}원</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
<span>파트너: {{ number_format($stats['total']['partner_commission']) }}원</span>
|
||||
<span class="mx-1">|</span>
|
||||
<span>매니저: {{ number_format($stats['total']['manager_commission']) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,432 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '회의록 AI 요약')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.upload-container { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone:hover, .drop-zone.dragover {
|
||||
border-color: #7c3aed;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
.drop-zone.uploading {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.drop-zone.dragover .drop-zone-icon { color: #7c3aed; }
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.file-icon { width: 40px; height: 40px; color: #7c3aed; }
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-waiting { background: #f1f5f9; color: #64748b; }
|
||||
.status-processing { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-completed { background: #dcfce7; color: #16a34a; }
|
||||
.status-error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.meeting-card {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.accordion-header { cursor: pointer; user-select: none; }
|
||||
.accordion-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
|
||||
.accordion-content.open { max-height: 500px; }
|
||||
.accordion-icon { transition: transform 0.3s ease; }
|
||||
.accordion-icon.open { transform: rotate(180deg); }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="upload-container">
|
||||
{{-- 헤더 --}}
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">회의록 AI 요약</h1>
|
||||
<p class="text-gray-500">회의 녹음 파일을 업로드하면 AI가 자동으로 회의록을 작성합니다</p>
|
||||
</div>
|
||||
|
||||
{{-- 업로드 섹션 --}}
|
||||
<div class="bg-white rounded-xl shadow-md mb-8 p-6">
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
@csrf
|
||||
{{-- 제목 입력 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">회의록 제목</label>
|
||||
<input type="text" name="title" id="titleInput"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent"
|
||||
placeholder="회의록 제목을 입력하세요 (선택사항)">
|
||||
</div>
|
||||
|
||||
{{-- 파일 드롭존 --}}
|
||||
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
||||
<svg class="drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-700 mb-2">파일을 드래그하거나 클릭하여 업로드</p>
|
||||
<p class="text-sm text-gray-500">지원 형식: WebM, WAV, MP3, OGG, M4A, MP4 (최대 100MB)</p>
|
||||
<input type="file" name="audio_file" id="fileInput" class="hidden"
|
||||
accept=".webm,.wav,.mp3,.ogg,.m4a,.mp4,audio/*">
|
||||
</div>
|
||||
|
||||
{{-- 선택된 파일 정보 --}}
|
||||
<div class="file-info hidden" id="fileInfo">
|
||||
<svg class="file-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-800 truncate" id="fileName"></p>
|
||||
<p class="text-sm text-gray-500" id="fileSize"></p>
|
||||
</div>
|
||||
<button type="button" class="text-gray-400 hover:text-gray-600" onclick="clearFile()">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 업로드 버튼 --}}
|
||||
<button type="submit" id="uploadBtn"
|
||||
class="w-full mt-6 py-3 px-6 bg-violet-600 hover:bg-violet-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
|
||||
disabled>
|
||||
<span id="uploadBtnText">파일을 선택해주세요</span>
|
||||
<span id="uploadBtnSpinner" class="hidden inline-flex items-center">
|
||||
<div class="spinner mr-2"></div>
|
||||
처리 중...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 처리 중 상태 --}}
|
||||
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="spinner" style="width:40px;height:40px;border-width:4px;"></div>
|
||||
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 회의록을 작성하고 있습니다</h3>
|
||||
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 중...</p>
|
||||
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
|
||||
<div class="h-full bg-violet-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 결과 섹션 --}}
|
||||
<div class="hidden" id="resultSection">
|
||||
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
AI 요약 결과
|
||||
</h2>
|
||||
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="resetForm()">새 파일 업로드</button>
|
||||
</div>
|
||||
|
||||
{{-- 요약 내용 --}}
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-semibold text-gray-800 mb-2">요약</h3>
|
||||
<div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
|
||||
</div>
|
||||
|
||||
{{-- 원본 텍스트 (아코디언) --}}
|
||||
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||
<span class="font-medium text-gray-800">원본 텍스트 보기</span>
|
||||
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="accordion-content">
|
||||
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 최근 회의록 목록 --}}
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
최근 회의록
|
||||
</h2>
|
||||
|
||||
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileInfo = document.getElementById('fileInfo');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
|
||||
// 드래그 앤 드롭 이벤트
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'));
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'));
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
showFileInfo(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 이벤트
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
showFileInfo(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 정보 표시
|
||||
function showFileInfo(file) {
|
||||
document.getElementById('fileName').textContent = file.name;
|
||||
document.getElementById('fileSize').textContent = formatFileSize(file.size);
|
||||
fileInfo.classList.remove('hidden');
|
||||
uploadBtn.disabled = false;
|
||||
document.getElementById('uploadBtnText').textContent = '회의록 생성하기';
|
||||
}
|
||||
|
||||
// 파일 크기 포맷
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// 파일 클리어
|
||||
function clearFile() {
|
||||
fileInput.value = '';
|
||||
fileInfo.classList.add('hidden');
|
||||
uploadBtn.disabled = true;
|
||||
document.getElementById('uploadBtnText').textContent = '파일을 선택해주세요';
|
||||
}
|
||||
|
||||
// 폼 리셋
|
||||
function resetForm() {
|
||||
clearFile();
|
||||
document.getElementById('titleInput').value = '';
|
||||
document.getElementById('resultSection').classList.add('hidden');
|
||||
document.getElementById('processingCard').classList.add('hidden');
|
||||
dropZone.classList.remove('uploading');
|
||||
}
|
||||
|
||||
// 아코디언 토글
|
||||
function toggleAccordion(header) {
|
||||
const content = header.nextElementSibling;
|
||||
const icon = header.querySelector('.accordion-icon');
|
||||
content.classList.toggle('open');
|
||||
icon.classList.toggle('open');
|
||||
}
|
||||
|
||||
// 목록 새로고침
|
||||
async function refreshMeetingList() {
|
||||
try {
|
||||
const response = await fetch('{{ route("api.admin.meeting-logs.index") }}', {
|
||||
headers: {
|
||||
'HX-Request': 'true',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
const html = await response.text();
|
||||
document.getElementById('meetingList').innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('목록 새로고침 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(uploadForm);
|
||||
|
||||
// UI 업데이트
|
||||
dropZone.classList.add('uploading');
|
||||
uploadBtn.disabled = true;
|
||||
document.getElementById('uploadBtnText').classList.add('hidden');
|
||||
document.getElementById('uploadBtnSpinner').classList.remove('hidden');
|
||||
document.getElementById('processingCard').classList.remove('hidden');
|
||||
document.getElementById('progressBar').style.width = '10%';
|
||||
document.getElementById('processingStatus').textContent = '파일 업로드 중...';
|
||||
|
||||
try {
|
||||
document.getElementById('progressBar').style.width = '30%';
|
||||
document.getElementById('processingStatus').textContent = '음성 인식 중...';
|
||||
|
||||
const response = await fetch('{{ route("api.admin.meeting-logs.upload") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
document.getElementById('progressBar').style.width = '90%';
|
||||
document.getElementById('processingStatus').textContent = 'AI 요약 생성 중...';
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
document.getElementById('progressBar').style.width = '100%';
|
||||
|
||||
if (result.success) {
|
||||
showResult(result.data);
|
||||
showToast('회의록이 생성되었습니다.', 'success');
|
||||
} else {
|
||||
throw new Error(result.message || '처리 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
resetUploadUI();
|
||||
}
|
||||
});
|
||||
|
||||
// 업로드 UI 리셋
|
||||
function resetUploadUI() {
|
||||
dropZone.classList.remove('uploading');
|
||||
uploadBtn.disabled = false;
|
||||
document.getElementById('uploadBtnText').classList.remove('hidden');
|
||||
document.getElementById('uploadBtnSpinner').classList.add('hidden');
|
||||
document.getElementById('processingCard').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 결과 표시
|
||||
function showResult(meeting) {
|
||||
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
|
||||
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
|
||||
|
||||
document.getElementById('processingCard').classList.add('hidden');
|
||||
document.getElementById('resultSection').classList.remove('hidden');
|
||||
resetUploadUI();
|
||||
|
||||
// 목록 새로고침
|
||||
refreshMeetingList();
|
||||
}
|
||||
|
||||
// 회의록 삭제
|
||||
async function deleteMeeting(id) {
|
||||
showDeleteConfirm('이 회의록', async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('삭제되었습니다.', 'success');
|
||||
refreshMeetingList();
|
||||
} else {
|
||||
showToast(result.message || '삭제 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 회의록 상세 보기
|
||||
async function viewMeeting(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
|
||||
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
|
||||
document.getElementById('resultSection').classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('조회 실패:', error);
|
||||
showToast('조회 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{{-- Markdown 파서 --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
@endpush
|
||||
@@ -1,62 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '운영자용 챗봇')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
|
||||
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
|
||||
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
|
||||
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
|
||||
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="placeholder-container">
|
||||
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h1 class="placeholder-title">운영자용 챗봇</h1>
|
||||
<p class="placeholder-subtitle">
|
||||
SAM 시스템 운영에 필요한 질문에 AI가 답변하고
|
||||
운영 매뉴얼과 문서를 기반으로 지원합니다.
|
||||
</p>
|
||||
<div class="placeholder-badge">AI/Automation</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto mt-12">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
예정 기능
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="p-4 bg-violet-50 rounded-lg">
|
||||
<h3 class="font-semibold text-violet-800 mb-2">운영 지원</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 시스템 운영 Q&A</li>
|
||||
<li>• 장애 대응 가이드</li>
|
||||
<li>• 설정 변경 안내</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">지식 기반</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 운영 매뉴얼 RAG</li>
|
||||
<li>• 과거 이슈 검색</li>
|
||||
<li>• 베스트 프랙티스 제안</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,62 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '테넌트 챗봇')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
|
||||
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
|
||||
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
|
||||
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
|
||||
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="placeholder-container">
|
||||
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
|
||||
</svg>
|
||||
<h1 class="placeholder-title">테넌트 챗봇</h1>
|
||||
<p class="placeholder-subtitle">
|
||||
각 테넌트의 업로드된 지식을 기반으로 동작하는
|
||||
맞춤형 AI 챗봇 인터페이스입니다.
|
||||
</p>
|
||||
<div class="placeholder-badge">AI/Automation</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto mt-12">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
예정 기능
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="p-4 bg-violet-50 rounded-lg">
|
||||
<h3 class="font-semibold text-violet-800 mb-2">챗봇 기능</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 실시간 대화 인터페이스</li>
|
||||
<li>• 대화 이력 저장</li>
|
||||
<li>• 멀티턴 컨텍스트</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">테넌트 격리</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 지식베이스 분리</li>
|
||||
<li>• 권한별 접근 제어</li>
|
||||
<li>• 사용량 모니터링</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,62 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '테넌트 지식 업로드')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
|
||||
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
|
||||
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
|
||||
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
|
||||
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="placeholder-container">
|
||||
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<h1 class="placeholder-title">테넌트 지식 업로드</h1>
|
||||
<p class="placeholder-subtitle">
|
||||
각 테넌트가 자체 문서와 지식을 업로드하여
|
||||
맞춤형 AI 챗봇을 구축할 수 있습니다.
|
||||
</p>
|
||||
<div class="placeholder-badge">AI/Automation</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto mt-12">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
예정 기능
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="p-4 bg-violet-50 rounded-lg">
|
||||
<h3 class="font-semibold text-violet-800 mb-2">문서 업로드</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• PDF, Word, Excel 지원</li>
|
||||
<li>• 웹페이지 크롤링</li>
|
||||
<li>• Notion/Confluence 연동</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">지식 관리</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 자동 벡터 인덱싱</li>
|
||||
<li>• 카테고리 분류</li>
|
||||
<li>• 버전 관리</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,62 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Vertex RAG 챗봇')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
|
||||
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
|
||||
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
|
||||
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
|
||||
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="placeholder-container">
|
||||
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<h1 class="placeholder-title">Vertex RAG 챗봇</h1>
|
||||
<p class="placeholder-subtitle">
|
||||
Google Vertex AI를 활용한 RAG(Retrieval-Augmented Generation)
|
||||
기반의 지능형 챗봇 시스템입니다.
|
||||
</p>
|
||||
<div class="placeholder-badge">AI/Automation</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto mt-12">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
예정 기능
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="p-4 bg-violet-50 rounded-lg">
|
||||
<h3 class="font-semibold text-violet-800 mb-2">Vertex AI 연동</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• Gemini 1.5 Pro 모델</li>
|
||||
<li>• Vector Search 활용</li>
|
||||
<li>• 문서 임베딩 자동화</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">RAG 파이프라인</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 문서 청킹 전략</li>
|
||||
<li>• 시맨틱 검색</li>
|
||||
<li>• 출처 표시 응답</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,553 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '웹 녹음 AI 요약')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.recording-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.record-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.record-button.recording {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.record-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); }
|
||||
50% { box-shadow: 0 4px 30px rgba(245, 87, 108, 0.8); }
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-height: 50px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.timer.active { color: #f5576c; }
|
||||
|
||||
.waveform {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
width: 4px;
|
||||
background: #667eea;
|
||||
border-radius: 2px;
|
||||
animation: wave 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { height: 10px; }
|
||||
50% { height: 40px; }
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-waiting { background: #f1f5f9; color: #64748b; }
|
||||
.status-recording { background: #fee2e2; color: #dc2626; }
|
||||
.status-processing { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-completed { background: #dcfce7; color: #16a34a; }
|
||||
.status-error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.meeting-card {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.accordion-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.accordion-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="recording-container">
|
||||
{{-- 헤더 --}}
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">웹 녹음 AI 요약</h1>
|
||||
<p class="text-gray-500">브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다</p>
|
||||
</div>
|
||||
|
||||
{{-- 녹음 섹션 --}}
|
||||
<div class="bg-white rounded-xl shadow-md mb-8 p-8">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
{{-- 타이머 --}}
|
||||
<div class="timer" id="timer">00:00</div>
|
||||
|
||||
{{-- 파형 (녹음 중에만 표시) --}}
|
||||
<div class="waveform hidden mt-4" id="waveform">
|
||||
@for($i = 0; $i < 20; $i++)
|
||||
<div class="waveform-bar" style="animation-delay: {{ $i * 0.05 }}s"></div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
{{-- 상태 표시 --}}
|
||||
<div class="my-4">
|
||||
<span class="status-badge status-waiting" id="statusBadge">대기 중</span>
|
||||
</div>
|
||||
|
||||
{{-- 녹음 버튼 --}}
|
||||
<div class="flex gap-4 items-center">
|
||||
<button class="record-button" id="recordBtn">
|
||||
<svg id="micIcon" class="w-10 h-10 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
<svg id="stopIcon" class="w-10 h-10 mx-auto hidden" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 안내 텍스트 --}}
|
||||
<p class="text-sm text-gray-500 mt-4" id="helpText">
|
||||
버튼을 클릭하여 녹음을 시작하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 처리 중 상태 (숨김) --}}
|
||||
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="spinner"></div>
|
||||
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 회의록을 작성하고 있습니다</h3>
|
||||
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 중...</p>
|
||||
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
|
||||
<div class="h-full bg-blue-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 결과 섹션 (숨김) --}}
|
||||
<div class="hidden" id="resultSection">
|
||||
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
AI 요약 결과
|
||||
</h2>
|
||||
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="newRecording()">새 녹음</button>
|
||||
</div>
|
||||
|
||||
{{-- 제목 입력 --}}
|
||||
<div class="mb-4">
|
||||
<input type="text" id="meetingTitle" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="회의록 제목을 입력하세요" />
|
||||
</div>
|
||||
|
||||
{{-- 요약 내용 --}}
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-semibold text-gray-800 mb-2">요약</h3>
|
||||
<div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
|
||||
</div>
|
||||
|
||||
{{-- 원본 텍스트 (아코디언) --}}
|
||||
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||
<span class="font-medium text-gray-800">원본 텍스트 보기</span>
|
||||
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="accordion-content">
|
||||
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 최근 회의록 목록 --}}
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
최근 회의록
|
||||
</h2>
|
||||
|
||||
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let timerInterval = null;
|
||||
let startTime = null;
|
||||
let currentMeetingId = null;
|
||||
|
||||
// 아코디언 토글
|
||||
function toggleAccordion(header) {
|
||||
const content = header.nextElementSibling;
|
||||
const icon = header.querySelector('.accordion-icon');
|
||||
content.classList.toggle('open');
|
||||
icon.classList.toggle('open');
|
||||
}
|
||||
|
||||
// 녹음 토글
|
||||
async function toggleRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
stopRecording();
|
||||
} else {
|
||||
await startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// 녹음 시작
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
await processAudio(audioBlob);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
// 회의록 생성
|
||||
const response = await fetch('{{ route("api.admin.meeting-logs.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ title: '무제 회의록' })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
currentMeetingId = result.data.id;
|
||||
}
|
||||
|
||||
mediaRecorder.start(1000);
|
||||
startTime = Date.now();
|
||||
updateUI('recording');
|
||||
startTimer();
|
||||
|
||||
} catch (error) {
|
||||
console.error('녹음 시작 실패:', error);
|
||||
showToast('마이크 접근 권한이 필요합니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 녹음 중지
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
stopTimer();
|
||||
updateUI('processing');
|
||||
}
|
||||
}
|
||||
|
||||
// 오디오 처리
|
||||
async function processAudio(audioBlob) {
|
||||
const duration = Math.floor((Date.now() - startTime) / 1000);
|
||||
|
||||
// Base64 변환
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
const base64Audio = reader.result;
|
||||
|
||||
try {
|
||||
updateProgress(10);
|
||||
document.getElementById('processingStatus').textContent = '서버에 업로드 중...';
|
||||
|
||||
const response = await fetch(`/api/meeting-logs/${currentMeetingId}/process`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
audio: base64Audio,
|
||||
duration: duration
|
||||
})
|
||||
});
|
||||
|
||||
updateProgress(100);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showResult(result.data);
|
||||
} else {
|
||||
throw new Error(result.message || '처리 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('처리 실패:', error);
|
||||
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
updateUI('ready');
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(audioBlob);
|
||||
}
|
||||
|
||||
// 진행률 업데이트
|
||||
function updateProgress(percent) {
|
||||
document.getElementById('progressBar').style.width = percent + '%';
|
||||
}
|
||||
|
||||
// 결과 표시
|
||||
function showResult(meeting) {
|
||||
document.getElementById('meetingTitle').value = meeting.title || '';
|
||||
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
|
||||
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
|
||||
|
||||
updateUI('result');
|
||||
|
||||
// 목록 새로고침
|
||||
refreshMeetingList();
|
||||
}
|
||||
|
||||
// UI 업데이트
|
||||
function updateUI(state) {
|
||||
const recordBtn = document.getElementById('recordBtn');
|
||||
const micIcon = document.getElementById('micIcon');
|
||||
const stopIcon = document.getElementById('stopIcon');
|
||||
const waveform = document.getElementById('waveform');
|
||||
const statusBadge = document.getElementById('statusBadge');
|
||||
const helpText = document.getElementById('helpText');
|
||||
const processingCard = document.getElementById('processingCard');
|
||||
const resultSection = document.getElementById('resultSection');
|
||||
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
recordBtn.classList.add('recording');
|
||||
micIcon.classList.add('hidden');
|
||||
stopIcon.classList.remove('hidden');
|
||||
waveform.classList.remove('hidden');
|
||||
statusBadge.textContent = '녹음 중';
|
||||
statusBadge.className = 'status-badge status-recording';
|
||||
helpText.textContent = '버튼을 클릭하여 녹음을 종료하세요';
|
||||
processingCard.classList.add('hidden');
|
||||
resultSection.classList.add('hidden');
|
||||
break;
|
||||
|
||||
case 'processing':
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.disabled = true;
|
||||
micIcon.classList.remove('hidden');
|
||||
stopIcon.classList.add('hidden');
|
||||
waveform.classList.add('hidden');
|
||||
statusBadge.textContent = '처리 중';
|
||||
statusBadge.className = 'status-badge status-processing';
|
||||
helpText.textContent = 'AI가 회의록을 작성하고 있습니다...';
|
||||
processingCard.classList.remove('hidden');
|
||||
resultSection.classList.add('hidden');
|
||||
updateProgress(0);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.disabled = false;
|
||||
micIcon.classList.remove('hidden');
|
||||
stopIcon.classList.add('hidden');
|
||||
waveform.classList.add('hidden');
|
||||
statusBadge.textContent = '완료';
|
||||
statusBadge.className = 'status-badge status-completed';
|
||||
helpText.textContent = '버튼을 클릭하여 새로운 녹음을 시작하세요';
|
||||
processingCard.classList.add('hidden');
|
||||
resultSection.classList.remove('hidden');
|
||||
break;
|
||||
|
||||
default: // ready
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.disabled = false;
|
||||
micIcon.classList.remove('hidden');
|
||||
stopIcon.classList.add('hidden');
|
||||
waveform.classList.add('hidden');
|
||||
statusBadge.textContent = '대기 중';
|
||||
statusBadge.className = 'status-badge status-waiting';
|
||||
helpText.textContent = '버튼을 클릭하여 녹음을 시작하세요';
|
||||
processingCard.classList.add('hidden');
|
||||
resultSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 타이머 시작
|
||||
function startTimer() {
|
||||
const timerEl = document.getElementById('timer');
|
||||
timerEl.classList.add('active');
|
||||
|
||||
timerInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
|
||||
const seconds = (elapsed % 60).toString().padStart(2, '0');
|
||||
timerEl.textContent = `${minutes}:${seconds}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 타이머 중지
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
document.getElementById('timer').classList.remove('active');
|
||||
}
|
||||
|
||||
// 새 녹음
|
||||
function newRecording() {
|
||||
currentMeetingId = null;
|
||||
document.getElementById('timer').textContent = '00:00';
|
||||
updateUI('ready');
|
||||
}
|
||||
|
||||
// 회의록 삭제
|
||||
async function deleteMeeting(id) {
|
||||
showDeleteConfirm('이 회의록', async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('삭제되었습니다.', 'success');
|
||||
refreshMeetingList();
|
||||
} else {
|
||||
showToast(result.message || '삭제 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 목록 새로고침
|
||||
async function refreshMeetingList() {
|
||||
try {
|
||||
const response = await fetch('{{ route("api.admin.meeting-logs.index") }}', {
|
||||
headers: {
|
||||
'HX-Request': 'true',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
const html = await response.text();
|
||||
document.getElementById('meetingList').innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('목록 새로고침 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 회의록 상세 보기
|
||||
async function viewMeeting(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
document.getElementById('meetingTitle').value = result.data.transcript ? '회의록' : '';
|
||||
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
|
||||
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
|
||||
updateUI('result');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('조회 실패:', error);
|
||||
showToast('조회 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 녹음 버튼 이벤트 리스너
|
||||
document.getElementById('recordBtn')?.addEventListener('click', toggleRecording);
|
||||
</script>
|
||||
|
||||
{{-- Markdown 파서 --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
@endpush
|
||||
@@ -1,60 +0,0 @@
|
||||
{{-- 회의록 목록 (HTMX partial) --}}
|
||||
@forelse($meetings as $meeting)
|
||||
<div class="meeting-card bg-white border border-gray-200 rounded-lg p-4 mb-3 hover:shadow-md"
|
||||
onclick="viewMeeting({{ $meeting->id }})">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-800">{{ $meeting->title }}</h3>
|
||||
<div class="flex items-center gap-2 mt-1 text-sm text-gray-500">
|
||||
<span>{{ $meeting->user?->name ?? '알 수 없음' }}</span>
|
||||
<span>|</span>
|
||||
<span>{{ $meeting->created_at->format('Y-m-d H:i') }}</span>
|
||||
@if($meeting->duration_seconds)
|
||||
<span>|</span>
|
||||
<span>{{ $meeting->formatted_duration }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@php
|
||||
$statusClass = match($meeting->status) {
|
||||
'PENDING' => 'bg-yellow-100 text-yellow-700',
|
||||
'PROCESSING' => 'bg-blue-100 text-blue-700',
|
||||
'COMPLETED' => 'bg-green-100 text-green-700',
|
||||
'FAILED' => 'bg-red-100 text-red-700',
|
||||
default => 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
@endphp
|
||||
<span class="text-xs px-2 py-1 rounded-full {{ $statusClass }}">
|
||||
{{ $meeting->status_label }}
|
||||
</span>
|
||||
<button class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
onclick="event.stopPropagation(); deleteMeeting({{ $meeting->id }})">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if($meeting->transcript_text)
|
||||
<p class="text-sm text-gray-600 mt-2 line-clamp-2">
|
||||
{{ Str::limit($meeting->transcript_text, 100) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
<p>저장된 회의록이 없습니다.</p>
|
||||
<p class="text-sm mt-1">새 녹음을 시작해보세요!</p>
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($meetings->hasPages())
|
||||
<div class="mt-4 flex justify-center">
|
||||
{{ $meetings->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -1,98 +0,0 @@
|
||||
{{-- 회의록 요약 결과 (HTMX partial) --}}
|
||||
@if($meeting->isProcessing())
|
||||
<div class="text-center py-8">
|
||||
<div class="spinner mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">회의록을 생성하고 있습니다...</p>
|
||||
<p class="text-sm text-gray-400">음성 인식 및 AI 요약 중</p>
|
||||
</div>
|
||||
@elseif($meeting->isCompleted())
|
||||
<div class="space-y-6">
|
||||
{{-- 제목 편집 --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text"
|
||||
id="meeting-title-{{ $meeting->id }}"
|
||||
value="{{ $meeting->title }}"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 rounded-lg font-semibold text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="회의록 제목">
|
||||
<button class="px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
onclick="updateMeetingTitle({{ $meeting->id }})">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 메타 정보 --}}
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>작성자: {{ $meeting->user?->name ?? '알 수 없음' }}</span>
|
||||
<span>|</span>
|
||||
<span>녹음 시간: {{ $meeting->formatted_duration }}</span>
|
||||
<span>|</span>
|
||||
<span>{{ $meeting->created_at->format('Y-m-d H:i') }}</span>
|
||||
</div>
|
||||
|
||||
{{-- 음성 인식 결과 --}}
|
||||
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||
<span class="font-medium text-gray-800">음성 인식 결과</span>
|
||||
<svg class="accordion-icon w-5 h-5 text-gray-500 open" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="accordion-content open">
|
||||
<div class="px-4 pb-4">
|
||||
<p class="whitespace-pre-wrap text-gray-600 text-sm">{{ $meeting->transcript_text ?: '음성 인식 결과가 없습니다.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- AI 요약 결과 --}}
|
||||
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||
<span class="font-medium text-gray-800">AI 요약</span>
|
||||
<svg class="accordion-icon w-5 h-5 text-gray-500 open" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="accordion-content open">
|
||||
<div class="px-4 pb-4 prose max-w-none text-sm">
|
||||
{!! nl2br(e($meeting->summary_text ?: 'AI 요약 결과가 없습니다.')) !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p>회의록 처리에 실패했습니다.</p>
|
||||
<p class="text-sm mt-1">다시 시도해주세요.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
async function updateMeetingTitle(id) {
|
||||
const title = document.getElementById('meeting-title-' + id).value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}/title`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ title: title })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('제목이 저장되었습니다.', 'success');
|
||||
htmx.trigger('#meetingList', 'load');
|
||||
} else {
|
||||
showToast(result.message || '저장 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,455 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '업무협의록 AI 요약')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.upload-container { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone:hover, .drop-zone.dragover {
|
||||
border-color: #0891b2;
|
||||
background: #ecfeff;
|
||||
}
|
||||
|
||||
.drop-zone.uploading {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.drop-zone.dragover .drop-zone-icon { color: #0891b2; }
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.file-icon { width: 40px; height: 40px; color: #0891b2; }
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #0891b2;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-waiting { background: #f1f5f9; color: #64748b; }
|
||||
.status-processing { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-completed { background: #dcfce7; color: #16a34a; }
|
||||
.status-error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.meeting-card {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.accordion-header { cursor: pointer; user-select: none; }
|
||||
.accordion-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
|
||||
.accordion-content.open { max-height: 500px; }
|
||||
.accordion-icon { transition: transform 0.3s ease; }
|
||||
.accordion-icon.open { transform: rotate(180deg); }
|
||||
|
||||
.info-box {
|
||||
background: linear-gradient(135deg, #ecfeff 0%, #cffafe 100%);
|
||||
border: 1px solid #22d3ee;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="upload-container">
|
||||
{{-- 헤더 --}}
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">업무협의록 AI 요약</h1>
|
||||
<p class="text-gray-500">고객사 미팅 녹음을 업로드하면 AI가 업무 협의 내용을 정리합니다</p>
|
||||
</div>
|
||||
|
||||
{{-- 안내 박스 --}}
|
||||
<div class="info-box">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-cyan-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-cyan-800">
|
||||
<p class="font-semibold mb-1">업무협의록 전용 AI 요약</p>
|
||||
<p>고객 요구사항, 합의 사항, 후속 조치(To-Do) 등 업무 협의에 특화된 구조로 정리됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 업로드 섹션 --}}
|
||||
<div class="bg-white rounded-xl shadow-md mb-8 p-6">
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<input type="hidden" name="summary_type" value="work-memo">
|
||||
|
||||
{{-- 제목 입력 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">협의록 제목</label>
|
||||
<input type="text" name="title" id="titleInput"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="예: OO기업 킥오프 미팅, XX프로젝트 요구사항 협의">
|
||||
</div>
|
||||
|
||||
{{-- 파일 드롭존 --}}
|
||||
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
||||
<svg class="drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-700 mb-2">파일을 드래그하거나 클릭하여 업로드</p>
|
||||
<p class="text-sm text-gray-500">지원 형식: WebM, WAV, MP3, OGG, M4A, MP4 (최대 100MB)</p>
|
||||
<input type="file" name="audio_file" id="fileInput" class="hidden"
|
||||
accept=".webm,.wav,.mp3,.ogg,.m4a,.mp4,audio/*">
|
||||
</div>
|
||||
|
||||
{{-- 선택된 파일 정보 --}}
|
||||
<div class="file-info hidden" id="fileInfo">
|
||||
<svg class="file-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-800 truncate" id="fileName"></p>
|
||||
<p class="text-sm text-gray-500" id="fileSize"></p>
|
||||
</div>
|
||||
<button type="button" class="text-gray-400 hover:text-gray-600" onclick="clearFile()">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 업로드 버튼 --}}
|
||||
<button type="submit" id="uploadBtn"
|
||||
class="w-full mt-6 py-3 px-6 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
|
||||
disabled>
|
||||
<span id="uploadBtnText">파일을 선택해주세요</span>
|
||||
<span id="uploadBtnSpinner" class="hidden inline-flex items-center">
|
||||
<div class="spinner mr-2"></div>
|
||||
처리 중...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 처리 중 상태 --}}
|
||||
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="spinner" style="width:40px;height:40px;border-width:4px;"></div>
|
||||
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 업무협의록을 작성하고 있습니다</h3>
|
||||
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 중...</p>
|
||||
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
|
||||
<div class="h-full bg-cyan-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 결과 섹션 --}}
|
||||
<div class="hidden" id="resultSection">
|
||||
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
업무협의록 AI 요약
|
||||
</h2>
|
||||
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="resetForm()">새 파일 업로드</button>
|
||||
</div>
|
||||
|
||||
{{-- 요약 내용 --}}
|
||||
<div class="bg-cyan-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-semibold text-gray-800 mb-2">협의록 요약</h3>
|
||||
<div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
|
||||
</div>
|
||||
|
||||
{{-- 원본 텍스트 (아코디언) --}}
|
||||
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
||||
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
||||
<span class="font-medium text-gray-800">원본 녹취록 보기</span>
|
||||
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="accordion-content">
|
||||
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 최근 협의록 목록 --}}
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
최근 협의록
|
||||
</h2>
|
||||
|
||||
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileInfo = document.getElementById('fileInfo');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
|
||||
// 드래그 앤 드롭 이벤트
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'));
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'));
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
showFileInfo(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 이벤트
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
showFileInfo(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 정보 표시
|
||||
function showFileInfo(file) {
|
||||
document.getElementById('fileName').textContent = file.name;
|
||||
document.getElementById('fileSize').textContent = formatFileSize(file.size);
|
||||
fileInfo.classList.remove('hidden');
|
||||
uploadBtn.disabled = false;
|
||||
document.getElementById('uploadBtnText').textContent = '협의록 생성하기';
|
||||
}
|
||||
|
||||
// 파일 크기 포맷
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// 파일 클리어
|
||||
function clearFile() {
|
||||
fileInput.value = '';
|
||||
fileInfo.classList.add('hidden');
|
||||
uploadBtn.disabled = true;
|
||||
document.getElementById('uploadBtnText').textContent = '파일을 선택해주세요';
|
||||
}
|
||||
|
||||
// 폼 리셋
|
||||
function resetForm() {
|
||||
clearFile();
|
||||
document.getElementById('titleInput').value = '';
|
||||
document.getElementById('resultSection').classList.add('hidden');
|
||||
document.getElementById('processingCard').classList.add('hidden');
|
||||
dropZone.classList.remove('uploading');
|
||||
}
|
||||
|
||||
// 아코디언 토글
|
||||
function toggleAccordion(header) {
|
||||
const content = header.nextElementSibling;
|
||||
const icon = header.querySelector('.accordion-icon');
|
||||
content.classList.toggle('open');
|
||||
icon.classList.toggle('open');
|
||||
}
|
||||
|
||||
// 목록 새로고침
|
||||
async function refreshMeetingList() {
|
||||
try {
|
||||
const response = await fetch('{{ route("api.admin.meeting-logs.index") }}', {
|
||||
headers: {
|
||||
'HX-Request': 'true',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
const html = await response.text();
|
||||
document.getElementById('meetingList').innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('목록 새로고침 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(uploadForm);
|
||||
|
||||
// UI 업데이트
|
||||
dropZone.classList.add('uploading');
|
||||
uploadBtn.disabled = true;
|
||||
document.getElementById('uploadBtnText').classList.add('hidden');
|
||||
document.getElementById('uploadBtnSpinner').classList.remove('hidden');
|
||||
document.getElementById('processingCard').classList.remove('hidden');
|
||||
document.getElementById('progressBar').style.width = '10%';
|
||||
document.getElementById('processingStatus').textContent = '파일 업로드 중...';
|
||||
|
||||
try {
|
||||
document.getElementById('progressBar').style.width = '30%';
|
||||
document.getElementById('processingStatus').textContent = '음성 인식 중...';
|
||||
|
||||
const response = await fetch('{{ route("api.admin.meeting-logs.upload") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
document.getElementById('progressBar').style.width = '90%';
|
||||
document.getElementById('processingStatus').textContent = 'AI 협의록 생성 중...';
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
document.getElementById('progressBar').style.width = '100%';
|
||||
|
||||
if (result.success) {
|
||||
showResult(result.data);
|
||||
showToast('업무협의록이 생성되었습니다.', 'success');
|
||||
} else {
|
||||
throw new Error(result.message || '처리 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
resetUploadUI();
|
||||
}
|
||||
});
|
||||
|
||||
// 업로드 UI 리셋
|
||||
function resetUploadUI() {
|
||||
dropZone.classList.remove('uploading');
|
||||
uploadBtn.disabled = false;
|
||||
document.getElementById('uploadBtnText').classList.remove('hidden');
|
||||
document.getElementById('uploadBtnSpinner').classList.add('hidden');
|
||||
document.getElementById('processingCard').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 결과 표시
|
||||
function showResult(meeting) {
|
||||
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
|
||||
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
|
||||
|
||||
document.getElementById('processingCard').classList.add('hidden');
|
||||
document.getElementById('resultSection').classList.remove('hidden');
|
||||
resetUploadUI();
|
||||
|
||||
// 목록 새로고침
|
||||
refreshMeetingList();
|
||||
}
|
||||
|
||||
// 협의록 삭제
|
||||
async function deleteMeeting(id) {
|
||||
showDeleteConfirm('이 협의록', async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('삭제되었습니다.', 'success');
|
||||
refreshMeetingList();
|
||||
} else {
|
||||
showToast(result.message || '삭제 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 협의록 상세 보기
|
||||
async function viewMeeting(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
|
||||
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
|
||||
document.getElementById('resultSection').classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('조회 실패:', error);
|
||||
showToast('조회 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{{-- Markdown 파서 --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
@endpush
|
||||
@@ -1,483 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '장기적 채권추심 전략')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
.presentation-container { width: 100%; min-height: calc(100vh - 80px); position: relative; overflow: hidden; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); }
|
||||
.slide { width: 100%; min-height: calc(100vh - 80px); display: none; align-items: flex-start; justify-content: center; padding: 40px; position: absolute; top: 0; left: 0; overflow-y: auto; overflow-x: hidden; }
|
||||
.slide.active { display: flex; animation: slideInRight 0.5s ease-out; }
|
||||
.slide-content { background: rgba(255, 255, 255, 0.98); border-radius: 24px; padding: 60px; max-width: 1200px; width: 100%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); animation: fadeIn 0.8s ease-out; margin: auto 0; border: 1px solid rgba(255, 255, 255, 0.2); }
|
||||
|
||||
.presentation-container h1 { color: #2563eb; font-size: 3em; margin-bottom: 20px; text-align: center; font-weight: 800; }
|
||||
.presentation-container h2 { color: #1e40af; font-size: 2.5em; margin-bottom: 30px; text-align: center; border-bottom: 3px solid #2563eb; padding-bottom: 15px; font-weight: 700; }
|
||||
.presentation-container h3 { color: #2563eb; font-size: 1.8em; margin: 25px 0 15px 0; font-weight: 700; }
|
||||
.presentation-container p, .presentation-container li { font-size: 1.2em; line-height: 1.8; color: #1e293b; margin-bottom: 15px; }
|
||||
.presentation-container ul { margin-left: 30px; }
|
||||
|
||||
.company-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin: 30px 0; }
|
||||
.info-card { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; padding: 25px; border-radius: 16px; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2); animation: scaleIn 0.5s ease-out; border: 1px solid rgba(255, 255, 255, 0.1); }
|
||||
.info-card h4 { font-size: 1.3em; margin-bottom: 10px; border-bottom: 2px solid rgba(255, 255, 255, 0.3); padding-bottom: 8px; }
|
||||
|
||||
.comparison-table { width: 100%; border-collapse: collapse; margin: 25px 0; font-size: 1em; }
|
||||
.comparison-table th, .comparison-table td { padding: 12px; border: 1px solid #ddd; text-align: left; }
|
||||
.comparison-table thead tr { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; }
|
||||
.comparison-table tbody tr:nth-of-type(even) { background: #f3f3f3; }
|
||||
|
||||
.feature-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin: 20px 0; }
|
||||
.feature-item { background: #f8fafc; padding: 20px; border-radius: 12px; border-left: 4px solid #2563eb; border: 1px solid #e2e8f0; border-left-width: 4px; }
|
||||
|
||||
.conclusion-box { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; padding: 30px; border-radius: 16px; margin: 20px 0; text-align: center; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2); }
|
||||
.conclusion-box p { color: white; }
|
||||
|
||||
.navigation { position: fixed; bottom: 30px; right: 30px; display: flex; gap: 15px; z-index: 1000; }
|
||||
.nav-btn { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; border: none; padding: 15px 30px; border-radius: 9999px; cursor: pointer; font-size: 1.1em; font-weight: 600; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); transition: all 0.3s ease; }
|
||||
.nav-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(37, 99, 235, 0.4); background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 100%); }
|
||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.slide-number { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: rgba(255, 255, 255, 0.95); padding: 12px 24px; border-radius: 9999px; font-size: 1.1em; color: #2563eb; font-weight: 700; z-index: 1000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid rgba(37, 99, 235, 0.1); }
|
||||
|
||||
@keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slide-content { padding: 30px; }
|
||||
.presentation-container h1 { font-size: 2em; } .presentation-container h2 { font-size: 1.8em; } .presentation-container h3 { font-size: 1.4em; }
|
||||
.presentation-container p, .presentation-container li { font-size: 1em; }
|
||||
.navigation { bottom: 15px; right: 15px; }
|
||||
.nav-btn { padding: 10px 20px; font-size: 0.9em; }
|
||||
.slide-number { bottom: 15px; left: 15px; padding: 8px 15px; font-size: 0.9em; }
|
||||
.comparison-table { font-size: 0.8em; }
|
||||
.comparison-table th, .comparison-table td { padding: 8px; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="presentation-container">
|
||||
<!-- Slide 1: Cover -->
|
||||
<div class="slide active">
|
||||
<div class="slide-content">
|
||||
<h1>장기적 채권추심 전략</h1>
|
||||
<h2 style="border: none; color: #2563eb;">SAM 프로젝트 - 중소기업 맞춤형 채권관리 시스템</h2>
|
||||
<div style="text-align: center; margin-top: 50px;">
|
||||
<p style="font-size: 1.5em; color: #1e40af; font-weight: 600;">기획 · 디자인 · 백엔드 · 프론트엔드</p>
|
||||
<p style="margin-top: 30px; color: #64748b;">2025년 채권추심 자동화 솔루션 - 중장기 발전 계획</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 2: Project Vision -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>프로젝트 비전</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">채권관리의 디지털 혁신</h3>
|
||||
<p style="font-size: 1.3em;">복잡한 채권추심 업무를 자동화하고, 효율적인 회수 관리를 지원하는 통합 플랫폼</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 목표</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> 채권 관리</h4><p>채권 등록부터 회수까지 전체 생애주기 관리</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg> 추심 활동</h4><p>전화, 문자, 우편 등 다양한 추심 활동 기록 및 자동화</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg> 법적 절차</h4><p>독촉장, 지급명령, 소송 등 법적 절차 관리</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 회수 관리</h4><p>회수 내역 기록 및 통계, 분석 리포트</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>주요 타겟 사용자</h3>
|
||||
<ul>
|
||||
<li><strong>중소기업:</strong> 외상 매출 관리 및 채권 회수</li>
|
||||
<li><strong>채권추심 대행사:</strong> 다수 채권 통합 관리</li>
|
||||
<li><strong>법무법인:</strong> 법적 절차 진행 관리</li>
|
||||
<li><strong>금융기관:</strong> 연체 채권 관리</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 3: Core Values -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>핵심 가치 제안</h2>
|
||||
<div class="company-info">
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> 자동화</h4><p>추심 활동 자동 스케줄링</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">월 80시간 업무 절감</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> 가시성</h4><p>채권 현황 및 회수율 실시간 모니터링</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">대시보드 한눈에 확인</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg> 법규 준수</h4><p>공정채권추심법 자동 준수</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">법적 리스크 최소화</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg> 접근성</h4><p>모바일 앱을 통한 현장 추심 기록</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">언제 어디서나 업무</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg> 통합성</h4><p>회계, ERP 시스템 연동</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">데이터 자동 동기화</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> 보안</h4><p>채무자 정보 암호화 및 접근 제어</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">개인정보보호법 준수</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 4: Planning - Requirements -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 기능 요구사항</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>1. 채권 관리</h3>
|
||||
<ul>
|
||||
<li>채권 등록 (채무자 정보, 채권 금액, 발생일)</li>
|
||||
<li>채권 상태 관리 (정상/연체/회수/손실)</li>
|
||||
<li>채무자 정보 관리 (연락처, 주소, 직장 정보)</li>
|
||||
<li>채권 분류 및 그룹 관리</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>2. 추심 활동 관리</h3>
|
||||
<ul>
|
||||
<li>전화 추심 기록 (통화 내용, 약속 일자)</li>
|
||||
<li>문자 발송 및 이력 관리</li>
|
||||
<li>우편물 발송 기록 (내용증명, 독촉장)</li>
|
||||
<li>방문 추심 기록 (GPS 위치, 사진)</li>
|
||||
<li>자동 추심 스케줄링</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>3. 법적 절차 관리</h3>
|
||||
<ul>
|
||||
<li>독촉장 자동 생성 및 발송</li>
|
||||
<li>지급명령 신청 관리</li>
|
||||
<li>소송 진행 현황 관리</li>
|
||||
<li>강제집행 절차 추적</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>4. 회수 관리</h3>
|
||||
<ul>
|
||||
<li>회수금 입금 기록</li>
|
||||
<li>분할 상환 관리</li>
|
||||
<li>회수율 통계</li>
|
||||
<li>미회수 채권 분석</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 5: User Scenarios -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 사용자 시나리오</h2>
|
||||
<div style="background: #dbeafe; padding: 25px; border-radius: 16px; margin: 20px 0; border-left: 5px solid #2563eb; border: 1px solid #bfdbfe;">
|
||||
<h3 style="margin-top: 0; color: #1e40af; font-weight: 700;">시나리오 1: 신규 채권 등록 프로세스</h3>
|
||||
<ol style="color: #333;">
|
||||
<li><strong>채권 발생:</strong> 매출 미수금 발생</li>
|
||||
<li><strong>채권 등록:</strong> 채무자 정보, 채권 금액, 발생일 입력</li>
|
||||
<li><strong>초기 접촉:</strong> 자동 문자 발송 (결제 요청)</li>
|
||||
<li><strong>추심 스케줄:</strong> 7일, 14일, 21일 후 추심 활동 자동 생성</li>
|
||||
<li><strong>진행 추적:</strong> 각 단계별 활동 기록</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div style="background: #e0e7ff; padding: 25px; border-radius: 16px; margin: 20px 0; border-left: 5px solid #1e40af; border: 1px solid #c7d2fe;">
|
||||
<h3 style="margin-top: 0; color: #1e40af; font-weight: 700;">시나리오 2: 전화 추심 활동</h3>
|
||||
<ol style="color: #333;">
|
||||
<li><strong>추심 대상 확인:</strong> 오늘 추심할 채권 목록 조회</li>
|
||||
<li><strong>전화 발신:</strong> 시스템 내 전화 기능 또는 외부 연동</li>
|
||||
<li><strong>통화 기록:</strong> 통화 내용, 채무자 반응, 약속 일자 입력</li>
|
||||
<li><strong>후속 조치:</strong> 약속일 알림 자동 설정</li>
|
||||
<li><strong>이력 관리:</strong> 모든 통화 내역 자동 저장</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div style="background: #dbeafe; padding: 25px; border-radius: 16px; margin: 20px 0; border-left: 5px solid #2563eb; border: 1px solid #bfdbfe;">
|
||||
<h3 style="margin-top: 0; color: #1e40af; font-weight: 700;">시나리오 3: 법적 절차 진행</h3>
|
||||
<ol style="color: #333;">
|
||||
<li><strong>절차 개시 결정:</strong> 임의 추심 실패 판단</li>
|
||||
<li><strong>독촉장 발송:</strong> 시스템에서 독촉장 자동 생성</li>
|
||||
<li><strong>지급명령 신청:</strong> 필요 서류 자동 작성</li>
|
||||
<li><strong>법원 진행 추적:</strong> 각 단계별 진행 상황 기록</li>
|
||||
<li><strong>강제집행:</strong> 집행 절차 관리 및 결과 기록</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 6-14: 나머지 슬라이드 (간략화) -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 고급 기능</h2>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg> 모바일 앱</h4><p>현장 추심 기록, 사진 첨부, GPS 위치 기록</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> 스마트 알림</h4><p>약속일 알림, 추심 예정일 알림</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> 대시보드</h4><p>채권 현황, 회수율, 부서별 성과</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> 문서 자동화</h4><p>독촉장, 내용증명 자동 생성</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg> ERP 연동</h4><p>회계 시스템 자동 연동</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> AI 추천</h4><p>최적 추심 시간 및 방법 추천</p></div>
|
||||
</div>
|
||||
<div style="margin-top: 30px; padding: 25px; background: #fef3c7; border-radius: 16px; border-left: 5px solid #f59e0b; border: 1px solid #fde68a;">
|
||||
<h3 style="margin-top: 0; color: #92400e; font-weight: 700;">컴플라이언스 기능</h3>
|
||||
<ul style="color: #78350f;">
|
||||
<li><strong>공정채권추심법 준수:</strong> 추심 시간, 횟수 자동 검증</li>
|
||||
<li><strong>개인정보보호법:</strong> 채무자 정보 암호화 및 접근 로그</li>
|
||||
<li><strong>전자문서법:</strong> 문서 전자서명 및 보관</li>
|
||||
<li><strong>감사 추적:</strong> 모든 추심 활동 이력 기록</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>디자인: UI/UX 전략</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">디자인 철학</h3>
|
||||
<p style="font-size: 1.2em;">"복잡한 채권 업무를 간단하게, 직관적으로"</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 디자인 원칙</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" 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> 사용자 중심</h4><p>추심 담당자가 쉽게 사용</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> 빠른 접근</h4><p>핵심 기능 2클릭 이내</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg> 모바일 우선</h4><p>현장에서 즉시 기록</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> 보안 강조</h4><p>개인정보 보호 시각화</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>주요 화면 구성</h3>
|
||||
<ul>
|
||||
<li><strong>관리자 대시보드:</strong> 채권 현황, 회수율, 부서별 성과</li>
|
||||
<li><strong>추심 담당자 대시보드:</strong> 오늘 할 일, 약속 일정, 미완료 건</li>
|
||||
<li><strong>채권 관리:</strong> 채권 등록, 조회, 수정, 상태 변경</li>
|
||||
<li><strong>추심 활동:</strong> 전화/문자/방문 기록, 일정 관리</li>
|
||||
<li><strong>법적 절차:</strong> 독촉장, 지급명령, 소송 관리</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>백엔드: 시스템 아키텍처</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">마이크로서비스 아키텍처</h3>
|
||||
<p style="font-size: 1.2em;">확장 가능하고 안정적인 채권관리 시스템</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>주요 서비스 구성</h3>
|
||||
<div class="company-info">
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> 채권 정보 서비스</h4><p>채권 및 채무자 정보 관리</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg> 추심 활동 서비스</h4><p>추심 활동 기록 및 스케줄링</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg> 법적 절차 서비스</h4><p>법적 절차 관리 및 문서 생성</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 회수 관리 서비스</h4><p>회수금 처리 및 통계</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> 알림 서비스</h4><p>푸시, 이메일, SMS 발송</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> 분석 서비스</h4><p>회수율 분석 및 리포트</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>기술 스택</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>레이어</th><th>기술</th><th>목적</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Application</strong></td><td>Node.js (NestJS) / Python (FastAPI)</td><td>비즈니스 로직 처리</td></tr>
|
||||
<tr><td><strong>Database</strong></td><td>PostgreSQL</td><td>관계형 데이터 저장</td></tr>
|
||||
<tr><td><strong>Cache</strong></td><td>Redis</td><td>세션 및 임시 데이터 캐싱</td></tr>
|
||||
<tr><td><strong>Message Queue</strong></td><td>RabbitMQ</td><td>비동기 처리 (알림 발송 등)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>백엔드: 데이터베이스 설계</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 테이블 구조</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>테이블명</th><th>주요 필드</th><th>관계</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>debtors</strong></td><td>id, name, phone, address, company</td><td>1:N debts</td></tr>
|
||||
<tr><td><strong>debts</strong></td><td>id, debtor_id, amount, due_date, status</td><td>N:1 debtors, 1:N activities</td></tr>
|
||||
<tr><td><strong>collection_activities</strong></td><td>id, debt_id, type, date, content, result</td><td>N:1 debts</td></tr>
|
||||
<tr><td><strong>legal_procedures</strong></td><td>id, debt_id, type, filed_date, status</td><td>N:1 debts</td></tr>
|
||||
<tr><td><strong>payments</strong></td><td>id, debt_id, amount, date, method</td><td>N:1 debts</td></tr>
|
||||
<tr><td><strong>schedules</strong></td><td>id, debt_id, due_date, type, completed</td><td>N:1 debts</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>데이터 보안 전략</h3>
|
||||
<ul>
|
||||
<li><strong>암호화:</strong> 주민번호, 계좌번호 AES-256 암호화</li>
|
||||
<li><strong>접근 제어:</strong> 역할 기반 접근 제어 (RBAC)</li>
|
||||
<li><strong>감사 로그:</strong> 모든 채권 및 추심 활동 이력 기록</li>
|
||||
<li><strong>백업:</strong> 일일 자동 백업 및 복구 테스트</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>프론트엔드: 기술 스택</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">현대적인 프론트엔드 아키텍처</h3>
|
||||
<p style="font-size: 1.2em;">React 18 + TypeScript 기반 SPA & 모바일 앱</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>웹 애플리케이션</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>카테고리</th><th>기술</th><th>목적</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>프레임워크</strong></td><td>React 18 + TypeScript</td><td>컴포넌트 기반 UI 구축</td></tr>
|
||||
<tr><td><strong>상태 관리</strong></td><td>Zustand / React Query</td><td>전역 상태 및 서버 상태 관리</td></tr>
|
||||
<tr><td><strong>UI 라이브러리</strong></td><td>Material-UI (MUI)</td><td>디자인 시스템 구현</td></tr>
|
||||
<tr><td><strong>차트</strong></td><td>Recharts</td><td>통계 데이터 시각화</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>모바일 애플리케이션</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>카테고리</th><th>기술</th><th>목적</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>프레임워크</strong></td><td>React Native</td><td>크로스 플랫폼 앱 개발</td></tr>
|
||||
<tr><td><strong>위치 서비스</strong></td><td>React Native Geolocation</td><td>방문 추심 GPS 기록</td></tr>
|
||||
<tr><td><strong>푸시 알림</strong></td><td>Firebase Cloud Messaging</td><td>실시간 알림 발송</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>개발 로드맵</h2>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>단계</th><th>기간</th><th>주요 마일스톤</th><th>산출물</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Phase 1: 기획</strong></td><td>4주</td><td>요구사항 정의, 시스템 설계</td><td>PRD, 아키텍처 문서</td></tr>
|
||||
<tr><td><strong>Phase 2: 디자인</strong></td><td>4주</td><td>UI/UX 설계, 디자인 시스템</td><td>Figma 프로토타입</td></tr>
|
||||
<tr><td><strong>Phase 3: MVP 개발</strong></td><td>12주</td><td>채권 관리, 추심 활동 핵심 기능</td><td>Alpha 버전</td></tr>
|
||||
<tr><td><strong>Phase 4: 통합 테스트</strong></td><td>4주</td><td>법적 절차 연동 및 테스트</td><td>Beta 버전</td></tr>
|
||||
<tr><td><strong>Phase 5: 파일럿</strong></td><td>4주</td><td>실사용자 테스트</td><td>개선사항 리스트</td></tr>
|
||||
<tr><td><strong>Phase 6: 정식 출시</strong></td><td>2주</td><td>버그 수정 및 최적화</td><td>v1.0 릴리스</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top: 30px; padding: 25px; background: #fef3c7; border-radius: 16px; border-left: 5px solid #f59e0b; border: 1px solid #fde68a;">
|
||||
<h3 style="margin-top: 0; color: #92400e; font-weight: 700;">예상 리소스</h3>
|
||||
<ul style="color: #78350f;">
|
||||
<li><strong>기획:</strong> PM 1명, 채권추심 전문가 1명</li>
|
||||
<li><strong>디자인:</strong> UI/UX 디자이너 2명</li>
|
||||
<li><strong>백엔드:</strong> 시니어 3명, 주니어 2명</li>
|
||||
<li><strong>프론트엔드:</strong> 웹 2명, 모바일 2명</li>
|
||||
<li><strong>QA:</strong> 테스터 2명</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>성공 지표</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3 class="flex items-center gap-2"><svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> 정량적 KPI</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>지표</th><th>목표치</th><th>측정 방법</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>업무 시간 절감</strong></td><td>60% 이상</td><td>추심 활동 소요 시간 비교</td></tr>
|
||||
<tr><td><strong>회수율 향상</strong></td><td>15% 증가</td><td>시스템 도입 전후 회수율 비교</td></tr>
|
||||
<tr><td><strong>사용자 만족도</strong></td><td>NPS 60 이상</td><td>분기별 만족도 조사</td></tr>
|
||||
<tr><td><strong>시스템 가용성</strong></td><td>99.9% 이상</td><td>Uptime 모니터링</td></tr>
|
||||
<tr><td><strong>모바일 사용률</strong></td><td>50% 이상</td><td>모바일 앱 활성 사용자</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>맺음말</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">채권관리의 새로운 패러다임</h3>
|
||||
<p style="font-size: 1.3em;">자동화로 시간을 절약하고, 체계적 관리로 회수율을 높이며, 법규 준수로 리스크를 최소화하는 채권추심 솔루션</p>
|
||||
</div>
|
||||
<div style="margin-top: 40px;">
|
||||
<h3>핵심 차별점</h3>
|
||||
<div class="company-info">
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M13 10V3L4 14h7v7l9-11h-7z" /></svg> 완전 자동화</h4><p>채권 등록부터 회수까지 End-to-End 자동화</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg> 모바일 우선</h4><p>현장에서 즉시 추심 활동 기록</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /></svg> 법규 준수</h4><p>공정채권추심법 자동 준수</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> 강력한 보안</h4><p>개인정보 암호화 및 접근 제어</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 40px; text-align: center;">
|
||||
<p style="font-size: 1.3em; color: #2563eb; font-weight: 700;"><strong>예상 효과</strong></p>
|
||||
<p style="font-size: 1.1em; margin-top: 20px;">업무 시간 <span style="background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; padding: 5px 10px; border-radius: 8px;">60% 절감</span> | 회수율 <span style="background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; padding: 5px 10px; border-radius: 8px;">15% 향상</span> | 법규 준수 <span style="background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; padding: 5px 10px; border-radius: 8px;">자동 보장</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h1 style="font-size: 4em; margin-bottom: 40px;">감사합니다</h1>
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 1.5em; color: #2563eb; margin-bottom: 30px; font-weight: 700;">장기적 채권추심 전략 - SAM 프로젝트</p>
|
||||
<div style="margin-top: 50px; padding: 30px; background: #f1f5f9; border-radius: 16px; border: 1px solid #e2e8f0;">
|
||||
<h3>문의 및 피드백</h3>
|
||||
<p style="margin-top: 20px;">본 계획안에 대한 의견이나 추가 논의가 필요하신 경우</p>
|
||||
<p>프로젝트 팀으로 연락 주시기 바랍니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide-number">
|
||||
<span id="currentSlide">1</span> / <span id="totalSlides">14</span>
|
||||
</div>
|
||||
|
||||
<div class="navigation">
|
||||
<button class="nav-btn" id="prevBtn" onclick="changeSlide(-1)">← 이전</button>
|
||||
<button class="nav-btn" id="nextBtn" onclick="changeSlide(1)">다음 →</button>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let currentSlide = 1;
|
||||
const totalSlides = 14;
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
|
||||
document.getElementById('totalSlides').textContent = totalSlides;
|
||||
|
||||
function showSlide(n) {
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
if (n > totalSlides) currentSlide = 1;
|
||||
if (n < 1) currentSlide = totalSlides;
|
||||
slides.forEach(slide => slide.classList.remove('active'));
|
||||
slides[currentSlide - 1].classList.add('active');
|
||||
document.getElementById('currentSlide').textContent = currentSlide;
|
||||
document.getElementById('prevBtn').disabled = (currentSlide === 1);
|
||||
document.getElementById('nextBtn').disabled = (currentSlide === totalSlides);
|
||||
}
|
||||
|
||||
function changeSlide(direction) {
|
||||
currentSlide += direction;
|
||||
showSlide(currentSlide);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'ArrowLeft') changeSlide(-1);
|
||||
else if (event.key === 'ArrowRight' || event.key === ' ') { event.preventDefault(); changeSlide(1); }
|
||||
});
|
||||
|
||||
document.addEventListener('touchstart', function(event) { touchStartX = event.changedTouches[0].screenX; });
|
||||
document.addEventListener('touchend', function(event) { touchEndX = event.changedTouches[0].screenX; handleSwipe(); });
|
||||
|
||||
function handleSwipe() {
|
||||
if (touchEndX < touchStartX - 50) changeSlide(1);
|
||||
if (touchEndX > touchStartX + 50) changeSlide(-1);
|
||||
}
|
||||
|
||||
showSlide(currentSlide);
|
||||
</script>
|
||||
@endpush
|
||||
@@ -1,117 +0,0 @@
|
||||
@extends('layouts.presentation')
|
||||
|
||||
@section('title', '해외 MRP 시스템 분석')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.placeholder-container {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.placeholder-icon {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.6;
|
||||
color: #059669;
|
||||
}
|
||||
.feature-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #059669;
|
||||
}
|
||||
.placeholder-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.placeholder-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #64748b;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.placeholder-badge {
|
||||
margin-top: 2rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gradient-to-br from-emerald-50 to-teal-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
|
||||
<div class="placeholder-container">
|
||||
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
|
||||
</svg>
|
||||
<h1 class="placeholder-title">해외 MRP 시스템 분석</h1>
|
||||
<p class="placeholder-subtitle">
|
||||
글로벌 MRP(Material Requirements Planning) 솔루션 비교와
|
||||
해외 제조 기업의 도입 사례를 분석합니다.
|
||||
</p>
|
||||
<div class="placeholder-badge">Coming Soon</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto mt-12">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
예정 콘텐츠
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="p-4 bg-emerald-50 rounded-lg">
|
||||
<h3 class="font-semibold text-emerald-800 mb-2">글로벌 MRP 솔루션</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• SAP S/4HANA</li>
|
||||
<li>• Oracle Cloud SCM</li>
|
||||
<li>• Microsoft Dynamics 365</li>
|
||||
<li>• Infor CloudSuite</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">기능 비교</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 수요 예측 알고리즘</li>
|
||||
<li>• 재고 최적화 기능</li>
|
||||
<li>• 공급망 가시성</li>
|
||||
<li>• AI/ML 통합 수준</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-purple-50 rounded-lg">
|
||||
<h3 class="font-semibold text-purple-800 mb-2">도입 사례</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 자동차 산업 적용 사례</li>
|
||||
<li>• 전자 제조업 도입 효과</li>
|
||||
<li>• 중소기업 맞춤 솔루션</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-orange-50 rounded-lg">
|
||||
<h3 class="font-semibold text-orange-800 mb-2">비용 분석</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 라이선스 모델 비교</li>
|
||||
<li>• 구현 비용 산정</li>
|
||||
<li>• TCO(총소유비용) 분석</li>
|
||||
<li>• ROI 기대 효과</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,872 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '장기적 세무전략')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
.presentation-container {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
}
|
||||
|
||||
.slide {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
display: flex;
|
||||
animation: slideInRight 0.5s ease-out;
|
||||
}
|
||||
|
||||
.slide-content {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
padding: 60px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
margin: auto 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.presentation-container h1 { color: #2563eb; font-size: 3em; margin-bottom: 20px; text-align: center; font-weight: 800; }
|
||||
.presentation-container h2 { color: #1e40af; font-size: 2.5em; margin-bottom: 30px; text-align: center; border-bottom: 3px solid #2563eb; padding-bottom: 15px; font-weight: 700; }
|
||||
.presentation-container h3 { color: #2563eb; font-size: 1.8em; margin: 25px 0 15px 0; font-weight: 700; }
|
||||
.presentation-container p, .presentation-container li { font-size: 1.2em; line-height: 1.8; color: #1e293b; margin-bottom: 15px; }
|
||||
.presentation-container ul { margin-left: 30px; }
|
||||
|
||||
.company-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin: 30px 0; }
|
||||
.info-card {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
|
||||
animation: scaleIn 0.5s ease-out;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.info-card h4 { font-size: 1.3em; margin-bottom: 10px; border-bottom: 2px solid rgba(255, 255, 255, 0.3); padding-bottom: 8px; }
|
||||
|
||||
.comparison-table { width: 100%; border-collapse: collapse; margin: 25px 0; font-size: 1em; }
|
||||
.comparison-table th, .comparison-table td { padding: 12px; border: 1px solid #ddd; text-align: left; }
|
||||
.comparison-table thead tr { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; }
|
||||
.comparison-table tbody tr:nth-of-type(even) { background: #f3f3f3; }
|
||||
|
||||
.pricing-table { width: 100%; border-collapse: collapse; margin: 25px 0; font-size: 1.1em; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); animation: slideInLeft 0.5s ease-out; }
|
||||
.pricing-table thead tr { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; text-align: left; }
|
||||
.pricing-table th, .pricing-table td { padding: 15px; border: 1px solid #ddd; }
|
||||
.pricing-table tbody tr { background: white; }
|
||||
.pricing-table tbody tr:nth-of-type(even) { background: #f3f3f3; }
|
||||
.pricing-table tbody tr:hover { background: #e8e8ff; }
|
||||
|
||||
.highlight { background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); color: white; padding: 5px 10px; border-radius: 8px; font-weight: 700; }
|
||||
|
||||
.navigation { position: fixed; bottom: 30px; right: 30px; display: flex; gap: 15px; z-index: 1000; }
|
||||
.nav-btn {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.nav-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(37, 99, 235, 0.4); background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 100%); }
|
||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.slide-number {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px 24px;
|
||||
border-radius: 9999px;
|
||||
font-size: 1.1em;
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.feature-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin: 20px 0; }
|
||||
.feature-item { background: #f8fafc; padding: 20px; border-radius: 12px; border-left: 4px solid #2563eb; border: 1px solid #e2e8f0; border-left-width: 4px; }
|
||||
|
||||
.conclusion-box {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
.conclusion-box p { color: white; }
|
||||
|
||||
.scenario-box {
|
||||
padding: 25px;
|
||||
border-radius: 16px;
|
||||
margin: 20px 0;
|
||||
border-left: 5px solid;
|
||||
}
|
||||
.scenario-box.blue { background: #dbeafe; border-left-color: #2563eb; }
|
||||
.scenario-box.indigo { background: #e0e7ff; border-left-color: #1e40af; }
|
||||
.scenario-box.amber { background: #fef3c7; border-left-color: #f59e0b; border: 1px solid #fde68a; }
|
||||
.scenario-box h3 { margin-top: 0; color: #1e40af; font-weight: 700; }
|
||||
|
||||
.info-box {
|
||||
margin-top: 30px;
|
||||
padding: 25px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 16px;
|
||||
border-left: 5px solid #2563eb;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.info-box h3 { margin-top: 0; }
|
||||
|
||||
@keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes slideInLeft { from { transform: translateX(-50px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slide-content { padding: 30px; }
|
||||
.presentation-container h1 { font-size: 2em; }
|
||||
.presentation-container h2 { font-size: 1.8em; }
|
||||
.presentation-container h3 { font-size: 1.4em; }
|
||||
.presentation-container p, .presentation-container li { font-size: 1em; }
|
||||
.navigation { bottom: 15px; right: 15px; }
|
||||
.nav-btn { padding: 10px 20px; font-size: 0.9em; }
|
||||
.slide-number { bottom: 15px; left: 50%; transform: translateX(-50%); padding: 8px 15px; font-size: 0.9em; }
|
||||
.pricing-table, .comparison-table { font-size: 0.8em; }
|
||||
.pricing-table th, .pricing-table td, .comparison-table th, .comparison-table td { padding: 8px; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="presentation-container">
|
||||
<!-- Slide 1: Cover -->
|
||||
<div class="slide active">
|
||||
<div class="slide-content">
|
||||
<h1>장기적 세무전략</h1>
|
||||
<h2 style="border: none; color: #2563eb;">통합 세무 관리 시스템 중장기 계획안</h2>
|
||||
<div style="text-align: center; margin-top: 50px;">
|
||||
<p style="font-size: 1.5em; color: #1e40af; font-weight: 600;">기획 · 디자인 · 백엔드 · 프론트엔드</p>
|
||||
<p style="margin-top: 30px; color: #64748b;">2025년 세무 자동화 솔루션 - 중장기 발전 계획</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 2: Table of Contents -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>목차</h2>
|
||||
<div style="margin-top: 40px;">
|
||||
<div class="feature-item"><h3 style="margin: 0; font-size: 1.3em;">1-4. 프로젝트 개요</h3><p style="margin: 10px 0 0 0;">비전, 목표, 핵심 가치, 기대 효과</p></div>
|
||||
<div class="feature-item"><h3 style="margin: 0; font-size: 1.3em;">5-8. 기획 관점</h3><p style="margin: 10px 0 0 0;">요구사항 정의, 사용자 시나리오, 기능 명세</p></div>
|
||||
<div class="feature-item"><h3 style="margin: 0; font-size: 1.3em;">9-11. 디자인 관점</h3><p style="margin: 10px 0 0 0;">UI/UX 전략, 디자인 시스템, 사용자 경험</p></div>
|
||||
<div class="feature-item"><h3 style="margin: 0; font-size: 1.3em;">12-14. 백엔드 관점</h3><p style="margin: 10px 0 0 0;">시스템 아키텍처, 데이터베이스 설계, API 설계</p></div>
|
||||
<div class="feature-item"><h3 style="margin: 0; font-size: 1.3em;">15-16. 프론트엔드 관점</h3><p style="margin: 10px 0 0 0;">기술 스택, 컴포넌트 구조, 개발 로드맵</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 3: Project Vision -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>프로젝트 비전</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">세무 업무의 디지털 혁신</h3>
|
||||
<p style="font-size: 1.3em;">복잡한 세무 업무를 자동화하고, 전략적 의사결정을 지원하는 통합 플랫폼</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 목표</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg> 업무 자동화</h4><p>반복적인 세무 업무를 90% 이상 자동화</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg> 데이터 통합</h4><p>회계, 인사, 영업 데이터의 실시간 통합</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg> 리스크 관리</h4><p>세무 리스크 사전 감지 및 알림</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" /></svg> 전략 수립</h4><p>데이터 기반 세무 전략 의사결정 지원</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>주요 타겟 사용자</h3>
|
||||
<ul>
|
||||
<li><strong>세무사/회계사:</strong> 전문가용 세무 관리 도구</li>
|
||||
<li><strong>기업 재무팀:</strong> 내부 세무 업무 자동화</li>
|
||||
<li><strong>중소기업:</strong> 간편한 세무 컴플라이언스 관리</li>
|
||||
<li><strong>스타트업:</strong> 초기 세무 구조 설계 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 4: Core Values -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>핵심 가치 제안</h2>
|
||||
<div class="company-info">
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.02 48.02 0 0112 21c-2.773 0-5.491-.235-8.135-.698-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" /></svg> 자동화</h4><p>세금 계산, 신고서 작성, 납부 관리 자동화</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">매월 80시간 업무 시간 절감</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg> 통합성</h4><p>회계 시스템, ERP, 급여 시스템 연동</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">데이터 이중 입력 제거</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /></svg> 접근성</h4><p>클라우드 기반 언제 어디서나 접근</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">PC, 태블릿, 모바일 지원</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 정확성</h4><p>최신 세법 자동 반영 및 검증</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">인적 오류 95% 감소</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg> 인사이트</h4><p>AI 기반 세무 최적화 제안</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">절세 기회 자동 발견</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></svg> 보안</h4><p>금융권 수준의 데이터 보안</p><p style="font-size: 0.9em; margin-top: 10px; opacity: 0.9;">암호화 및 접근 제어</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 5: Planning - Requirements -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 기능 요구사항</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>1. 세무 신고 관리</h3>
|
||||
<ul>
|
||||
<li>부가가치세 신고서 자동 작성 및 전송</li>
|
||||
<li>법인세/소득세 예정/확정 신고 관리</li>
|
||||
<li>원천세 신고 및 납부 스케줄링</li>
|
||||
<li>지방세 통합 관리 (취득세, 재산세 등)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>2. 세무 회계 처리</h3>
|
||||
<ul>
|
||||
<li>매입매출 전표 자동 분개 처리</li>
|
||||
<li>계정과목 자동 매핑 및 학습</li>
|
||||
<li>세무조정 항목 관리</li>
|
||||
<li>손익계산서 및 재무상태표 생성</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>3. 증빙 관리</h3>
|
||||
<ul>
|
||||
<li>전자세금계산서 발행 및 수신</li>
|
||||
<li>현금영수증 및 카드매출 통합</li>
|
||||
<li>증빙 스캔 및 OCR 자동 인식</li>
|
||||
<li>증빙 보관 및 검색 시스템</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 6: User Scenarios -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 사용자 시나리오</h2>
|
||||
<div class="scenario-box blue">
|
||||
<h3>시나리오 1: 부가세 신고 프로세스</h3>
|
||||
<ol style="color: #333;">
|
||||
<li><strong>데이터 수집:</strong> ERP 및 회계 시스템에서 매입/매출 데이터 자동 수집</li>
|
||||
<li><strong>검증:</strong> AI 기반 이상 거래 탐지 및 알림</li>
|
||||
<li><strong>신고서 작성:</strong> 부가세 신고서 자동 생성 및 미리보기</li>
|
||||
<li><strong>승인:</strong> 담당자 검토 및 전자서명</li>
|
||||
<li><strong>전송:</strong> 국세청 홈택스 자동 전송</li>
|
||||
<li><strong>납부:</strong> 계좌 연동 자동 납부 또는 납부서 출력</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="scenario-box indigo">
|
||||
<h3>시나리오 2: 절세 전략 수립</h3>
|
||||
<ol style="color: #333;">
|
||||
<li><strong>현황 분석:</strong> 현재 세무 부담 및 비용 구조 분석</li>
|
||||
<li><strong>시뮬레이션:</strong> 다양한 절세 시나리오 자동 시뮬레이션</li>
|
||||
<li><strong>추천:</strong> AI 기반 최적 절세 방안 제시</li>
|
||||
<li><strong>실행:</strong> 선택한 전략의 단계별 실행 가이드</li>
|
||||
<li><strong>모니터링:</strong> 효과 추적 및 리포팅</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 7: Advanced Features -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 고급 기능</h2>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.02 48.02 0 0112 21c-2.773 0-5.491-.235-8.135-.698-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" /></svg> AI 세무 어시스턴트</h4><p>자연어 질의응답으로 세무 문의 즉시 해결</p><p style="font-size: 0.9em; margin-top: 5px; color: #666;">"이번 달 부가세 예상액은?" 같은 질문에 즉시 답변</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg> 세무 대시보드</h4><p>실시간 세무 현황 및 KPI 모니터링</p><p style="font-size: 0.9em; margin-top: 5px; color: #666;">납부 일정, 세부담률, 공제 현황 한눈에 확인</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg> 리스크 알림</h4><p>세무 리스크 사전 감지 및 예방</p><p style="font-size: 0.9em; margin-top: 5px; color: #666;">신고 누락, 기한 임박, 이상 거래 자동 알림</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" /></svg> 절세 시뮬레이터</h4><p>다양한 시나리오별 세금 영향 분석</p><p style="font-size: 0.9em; margin-top: 5px; color: #666;">투자, 채용, 구조조정 시 세무 영향 사전 검토</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg> 외부 시스템 연동</h4><p>ERP, 급여, 회계 시스템 자동 연동</p><p style="font-size: 0.9em; margin-top: 5px; color: #666;">더존, 영림원, SAP, 더블린 등 주요 시스템 지원</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /></svg> 모바일 승인</h4><p>언제 어디서나 신고서 검토 및 승인</p><p style="font-size: 0.9em; margin-top: 5px; color: #666;">푸시 알림 및 생체인증 기반 전자서명</p></div>
|
||||
</div>
|
||||
<div class="scenario-box amber">
|
||||
<h3 style="color: #92400e;">컴플라이언스 기능</h3>
|
||||
<ul style="color: #78350f;">
|
||||
<li><strong>세법 자동 업데이트:</strong> 개정 세법 자동 반영 및 영향 분석</li>
|
||||
<li><strong>전자신고 통합:</strong> 홈택스, 위택스 등 전자신고 시스템 연동</li>
|
||||
<li><strong>감사 추적:</strong> 모든 세무 처리 이력 완벽 기록 및 추적</li>
|
||||
<li><strong>권한 관리:</strong> 역할 기반 접근 제어 및 승인 워크플로우</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 8: User Roles -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>기획: 사용자 권한 체계</h2>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>역할</th><th>주요 권한</th><th>접근 범위</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>시스템 관리자</strong></td><td>전체 시스템 설정, 사용자 관리, 감사 로그 조회</td><td>전체</td></tr>
|
||||
<tr><td><strong>세무 책임자</strong></td><td>신고서 최종 승인, 전략 수립, 리포트 생성</td><td>전체 세무 데이터</td></tr>
|
||||
<tr><td><strong>세무 담당자</strong></td><td>신고서 작성, 증빙 처리, 세무조정</td><td>담당 업무 데이터</td></tr>
|
||||
<tr><td><strong>회계 담당자</strong></td><td>전표 입력, 계정 관리, 증빙 등록</td><td>회계 데이터</td></tr>
|
||||
<tr><td><strong>경영진</strong></td><td>대시보드 조회, 리포트 확인 (읽기 전용)</td><td>요약 데이터</td></tr>
|
||||
<tr><td><strong>외부 세무사</strong></td><td>자문, 검토, 신고 대행 (제한적 접근)</td><td>위임받은 데이터</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>워크플로우 승인 체계</h3>
|
||||
<div style="background: #f1f5f9; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #e2e8f0;">
|
||||
<p><strong>1단계:</strong> 세무 담당자가 신고서 작성</p>
|
||||
<p><strong>2단계:</strong> 회계 담당자가 데이터 검증</p>
|
||||
<p><strong>3단계:</strong> 세무 책임자가 최종 승인</p>
|
||||
<p><strong>4단계:</strong> 시스템 자동 전송 또는 경영진 통보</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 9: UI/UX Strategy -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>디자인: UI/UX 전략</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">디자인 철학</h3>
|
||||
<p style="font-size: 1.2em;">"복잡한 세무를 단순하게, 전문성을 직관적으로"</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 디자인 원칙</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 단순성</h4><p>복잡한 세무 프로세스를 단계별로 분해</p><p style="font-size: 0.9em; color: #666;">마법사(Wizard) 형태의 진행 방식</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg> 시각화</h4><p>숫자와 데이터를 직관적인 차트로 표현</p><p style="font-size: 0.9em; color: #666;">인포그래픽 중심의 대시보드</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg> 효율성</h4><p>최소 클릭으로 작업 완료</p><p style="font-size: 0.9em; color: #666;">자주 쓰는 기능 빠른 실행</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" /></svg> 일관성</h4><p>통일된 디자인 언어 및 패턴</p><p style="font-size: 0.9em; color: #666;">학습 곡선 최소화</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /></svg> 접근성</h4><p>WCAG 2.1 AA 레벨 준수</p><p style="font-size: 0.9em; color: #666;">키보드 내비게이션 완벽 지원</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /></svg> 반응형</h4><p>모든 디바이스에서 최적 경험</p><p style="font-size: 0.9em; color: #666;">PC, 태블릿, 모바일 대응</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>컬러 시스템</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-top: 15px;">
|
||||
<div style="background: #2563eb; color: white; padding: 20px; border-radius: 12px; text-align: center;"><p style="margin: 0; color: white; font-weight: 700;"><strong>Primary</strong></p><p style="margin: 5px 0 0 0; font-size: 0.9em; color: white;">#2563eb</p></div>
|
||||
<div style="background: #10b981; color: white; padding: 20px; border-radius: 12px; text-align: center;"><p style="margin: 0; color: white; font-weight: 700;"><strong>Success</strong></p><p style="margin: 5px 0 0 0; font-size: 0.9em; color: white;">#10b981</p></div>
|
||||
<div style="background: #f59e0b; color: white; padding: 20px; border-radius: 12px; text-align: center;"><p style="margin: 0; color: white; font-weight: 700;"><strong>Warning</strong></p><p style="margin: 5px 0 0 0; font-size: 0.9em; color: white;">#f59e0b</p></div>
|
||||
<div style="background: #ef4444; color: white; padding: 20px; border-radius: 12px; text-align: center;"><p style="margin: 0; color: white; font-weight: 700;"><strong>Danger</strong></p><p style="margin: 5px 0 0 0; font-size: 0.9em; color: white;">#ef4444</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 10: Design System -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>디자인: 디자인 시스템</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>컴포넌트 라이브러리</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>컴포넌트</th><th>용도</th><th>변형</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Button</strong></td><td>주요 액션 실행</td><td>Primary, Secondary, Outline, Text</td></tr>
|
||||
<tr><td><strong>Card</strong></td><td>정보 그룹핑</td><td>Basic, Elevated, Outlined</td></tr>
|
||||
<tr><td><strong>Table</strong></td><td>데이터 표시</td><td>Sortable, Filterable, Paginated</td></tr>
|
||||
<tr><td><strong>Form</strong></td><td>데이터 입력</td><td>Input, Select, Datepicker, Autocomplete</td></tr>
|
||||
<tr><td><strong>Modal</strong></td><td>팝업 대화상자</td><td>Alert, Confirm, Form</td></tr>
|
||||
<tr><td><strong>Chart</strong></td><td>데이터 시각화</td><td>Line, Bar, Pie, Donut</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>타이포그래피</h3>
|
||||
<div style="background: #f1f5f9; padding: 20px; border-radius: 12px; border: 1px solid #e2e8f0;">
|
||||
<p><strong>본문:</strong> Noto Sans KR (가독성 최적화)</p>
|
||||
<p><strong>숫자:</strong> Roboto Mono (표 및 금액 표시)</p>
|
||||
<p><strong>제목:</strong> Spoqa Han Sans (명확한 위계)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>아이콘 시스템</h3>
|
||||
<ul>
|
||||
<li>Material Icons (기본 UI 아이콘)</li>
|
||||
<li>Custom Tax Icons (세무 특화 아이콘)</li>
|
||||
<li>일관된 24x24px 그리드 시스템</li>
|
||||
<li>Outlined, Filled 스타일 제공</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 11: User Experience -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>디자인: 사용자 경험 설계</h2>
|
||||
<div class="scenario-box blue">
|
||||
<h3 style="color: #004085;">대시보드 레이아웃</h3>
|
||||
<ul style="color: #333;">
|
||||
<li><strong>상단:</strong> 주요 KPI 카드 (이번 달 납부 세액, 공제액, 세부담률)</li>
|
||||
<li><strong>중단:</strong> 일정 타임라인 (다가오는 신고 및 납부 일정)</li>
|
||||
<li><strong>하단:</strong> 상세 차트 (월별 세금 추이, 업종 비교)</li>
|
||||
<li><strong>사이드:</strong> 빠른 실행 메뉴 및 알림 센터</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="scenario-box indigo">
|
||||
<h3 style="color: #6a1b9a;">신고서 작성 UX</h3>
|
||||
<ol style="color: #333;">
|
||||
<li><strong>Step 1 - 기간 선택:</strong> 신고 대상 기간 및 신고 유형 선택</li>
|
||||
<li><strong>Step 2 - 데이터 확인:</strong> 자동 수집된 데이터 검토 및 수정</li>
|
||||
<li><strong>Step 3 - 계산 검증:</strong> 세액 계산 결과 미리보기</li>
|
||||
<li><strong>Step 4 - 첨부 서류:</strong> 필요 서류 업로드 및 전자서명</li>
|
||||
<li><strong>Step 5 - 최종 확인:</strong> 요약 정보 확인 및 제출</li>
|
||||
</ol>
|
||||
<p style="margin-top: 15px; color: #333;"><strong>진행 표시:</strong> 각 단계별 진행 상황 프로그레스 바 표시</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>피드백 및 알림 시스템</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 성공 메시지</h4><p>작업 완료 시 명확한 확인 메시지</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg> 경고 알림</h4><p>주의가 필요한 항목 강조 표시</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 오류 안내</h4><p>문제 발생 시 해결 방법 제시</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg> 도움말</h4><p>컨텍스트 기반 도움말 제공</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 12: Backend Architecture -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>백엔드: 시스템 아키텍처</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">마이크로서비스 아키텍처</h3>
|
||||
<p style="font-size: 1.2em;">확장 가능하고 유지보수가 쉬운 분산 시스템</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>주요 서비스 구성</h3>
|
||||
<div class="company-info">
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></svg> 인증 서비스</h4><p>JWT 기반 인증 및 권한 관리</p><p style="font-size: 0.9em; margin-top: 5px;">OAuth 2.0, 2FA 지원</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg> 데이터 수집 서비스</h4><p>외부 시스템 데이터 수집 및 변환</p><p style="font-size: 0.9em; margin-top: 5px;">ETL 파이프라인</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V13.5zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V18zm2.498-6.75h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V13.5zm0 2.25h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V18zm2.504-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V18zm2.498-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zM8.25 6h7.5v2.25h-7.5V6zM12 2.25c-1.892 0-3.758.11-5.593.322C5.307 2.7 4.5 3.65 4.5 4.757V19.5a2.25 2.25 0 002.25 2.25h10.5a2.25 2.25 0 002.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507 48.507 0 0012 2.25z" /></svg> 계산 엔진</h4><p>세금 계산 및 시뮬레이션</p><p style="font-size: 0.9em; margin-top: 5px;">규칙 엔진 기반</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></svg> 신고서 생성</h4><p>각종 신고서 자동 작성</p><p style="font-size: 0.9em; margin-top: 5px;">PDF/XML 변환</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg> 통합 서비스</h4><p>ERP 및 회계 시스템 연동</p><p style="font-size: 0.9em; margin-top: 5px;">API Gateway</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.02 48.02 0 0112 21c-2.773 0-5.491-.235-8.135-.698-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" /></svg> AI 서비스</h4><p>자연어 처리 및 예측 분석</p><p style="font-size: 0.9em; margin-top: 5px;">TensorFlow/PyTorch</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>기술 스택</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>레이어</th><th>기술</th><th>목적</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>API Gateway</strong></td><td>Kong / AWS API Gateway</td><td>라우팅, 인증, 속도 제한</td></tr>
|
||||
<tr><td><strong>Application</strong></td><td>Node.js (NestJS) / Python (FastAPI)</td><td>비즈니스 로직 처리</td></tr>
|
||||
<tr><td><strong>Database</strong></td><td>PostgreSQL / MongoDB</td><td>관계형/문서형 데이터 저장</td></tr>
|
||||
<tr><td><strong>Cache</strong></td><td>Redis</td><td>세션, 계산 결과 캐싱</td></tr>
|
||||
<tr><td><strong>Message Queue</strong></td><td>RabbitMQ / Apache Kafka</td><td>비동기 처리, 이벤트 스트리밍</td></tr>
|
||||
<tr><td><strong>Search</strong></td><td>Elasticsearch</td><td>전체 텍스트 검색, 로그 분석</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 13: Database Design -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>백엔드: 데이터베이스 설계</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 테이블 구조</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>테이블명</th><th>주요 필드</th><th>관계</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>companies</strong></td><td>id, name, business_number, tax_type</td><td>1:N users, tax_returns</td></tr>
|
||||
<tr><td><strong>users</strong></td><td>id, email, role, company_id</td><td>N:1 companies</td></tr>
|
||||
<tr><td><strong>tax_returns</strong></td><td>id, type, period, status, company_id</td><td>N:1 companies, 1:N items</td></tr>
|
||||
<tr><td><strong>tax_items</strong></td><td>id, return_id, account, amount</td><td>N:1 tax_returns</td></tr>
|
||||
<tr><td><strong>receipts</strong></td><td>id, type, date, amount, file_path</td><td>N:1 companies</td></tr>
|
||||
<tr><td><strong>tax_rules</strong></td><td>id, name, formula, effective_date</td><td>독립 테이블</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>데이터 보안 전략</h3>
|
||||
<ul>
|
||||
<li><strong>암호화:</strong> 민감 데이터 AES-256 암호화 (금액, 계좌번호 등)</li>
|
||||
<li><strong>접근 제어:</strong> Row-Level Security (RLS) 적용</li>
|
||||
<li><strong>감사 로그:</strong> 모든 데이터 변경 이력 자동 기록</li>
|
||||
<li><strong>백업:</strong> 일일 자동 백업 및 Point-in-Time Recovery 지원</li>
|
||||
<li><strong>다중화:</strong> Master-Slave Replication으로 고가용성 확보</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h3>성능 최적화</h3>
|
||||
<ul>
|
||||
<li>주요 쿼리 대상 컬럼에 인덱스 생성</li>
|
||||
<li>자주 조회되는 집계 데이터 Materialized View 활용</li>
|
||||
<li>파티셔닝: 연도별 세금 신고 데이터 분할</li>
|
||||
<li>커넥션 풀링으로 DB 연결 관리 최적화</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 14: API Design -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>백엔드: API 설계</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>RESTful API 엔드포인트</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>엔드포인트</th><th>메서드</th><th>설명</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>/api/v1/auth/login</td><td>POST</td><td>사용자 로그인 및 JWT 발급</td></tr>
|
||||
<tr><td>/api/v1/tax-returns</td><td>GET</td><td>세금 신고서 목록 조회</td></tr>
|
||||
<tr><td>/api/v1/tax-returns</td><td>POST</td><td>새 신고서 생성</td></tr>
|
||||
<tr><td>/api/v1/tax-returns/:id</td><td>PUT</td><td>신고서 수정</td></tr>
|
||||
<tr><td>/api/v1/tax-returns/:id/submit</td><td>POST</td><td>신고서 제출</td></tr>
|
||||
<tr><td>/api/v1/receipts/upload</td><td>POST</td><td>증빙 파일 업로드 및 OCR 처리</td></tr>
|
||||
<tr><td>/api/v1/dashboard/summary</td><td>GET</td><td>대시보드 요약 정보</td></tr>
|
||||
<tr><td>/api/v1/ai/ask</td><td>POST</td><td>AI 세무 어시스턴트 질의</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>API 보안 및 품질</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></svg> 인증/인가</h4><p>JWT Bearer Token 기반 인증</p><p style="font-size: 0.9em; color: #666;">역할 기반 접근 제어 (RBAC)</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></svg> 문서화</h4><p>OpenAPI 3.0 (Swagger) 자동 생성</p><p style="font-size: 0.9em; color: #666;">인터랙티브 API 문서</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg> 속도 제한</h4><p>Rate Limiting (100 req/min/user)</p><p style="font-size: 0.9em; color: #666;">DDoS 방어</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> 입력 검증</h4><p>DTO 기반 자동 검증</p><p style="font-size: 0.9em; color: #666;">타입 안전성 보장</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg> 모니터링</h4><p>API 성능 및 오류율 추적</p><p style="font-size: 0.9em; color: #666;">Prometheus + Grafana</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg> 버전 관리</h4><p>URL 기반 버저닝 (/v1/, /v2/)</p><p style="font-size: 0.9em; color: #666;">하위 호환성 보장</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 15: Frontend Tech Stack -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>프론트엔드: 기술 스택</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">현대적인 프론트엔드 아키텍처</h3>
|
||||
<p style="font-size: 1.2em;">React 18 + TypeScript 기반 SPA</p>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>핵심 기술 스택</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>카테고리</th><th>기술</th><th>목적</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>프레임워크</strong></td><td>React 18</td><td>컴포넌트 기반 UI 구축</td></tr>
|
||||
<tr><td><strong>언어</strong></td><td>TypeScript</td><td>타입 안전성, 개발 생산성</td></tr>
|
||||
<tr><td><strong>상태 관리</strong></td><td>Zustand / React Query</td><td>전역 상태 및 서버 상태 관리</td></tr>
|
||||
<tr><td><strong>라우팅</strong></td><td>React Router v6</td><td>SPA 페이지 라우팅</td></tr>
|
||||
<tr><td><strong>UI 라이브러리</strong></td><td>Material-UI (MUI)</td><td>디자인 시스템 구현</td></tr>
|
||||
<tr><td><strong>차트</strong></td><td>Recharts / Chart.js</td><td>데이터 시각화</td></tr>
|
||||
<tr><td><strong>폼 관리</strong></td><td>React Hook Form</td><td>폼 검증 및 상태 관리</td></tr>
|
||||
<tr><td><strong>스타일링</strong></td><td>Emotion / Styled Components</td><td>CSS-in-JS</td></tr>
|
||||
<tr><td><strong>빌드 도구</strong></td><td>Vite</td><td>빠른 개발 서버 및 빌드</td></tr>
|
||||
<tr><td><strong>테스트</strong></td><td>Vitest / Testing Library</td><td>유닛/통합 테스트</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h3>개발 도구</h3>
|
||||
<ul>
|
||||
<li><strong>코드 품질:</strong> ESLint, Prettier, Husky (pre-commit hooks)</li>
|
||||
<li><strong>번들 분석:</strong> Vite Bundle Analyzer로 최적화</li>
|
||||
<li><strong>성능 모니터링:</strong> React DevTools, Lighthouse CI</li>
|
||||
<li><strong>문서화:</strong> Storybook으로 컴포넌트 문서화</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 16: Component Structure -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>프론트엔드: 컴포넌트 구조</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>폴더 구조</h3>
|
||||
<div style="background: #f1f5f9; padding: 20px; border-radius: 12px; font-family: monospace; font-size: 0.9em; border: 1px solid #e2e8f0;">
|
||||
<pre style="margin: 0; color: #333;">src/
|
||||
├── components/ # 재사용 가능 컴포넌트
|
||||
│ ├── common/ # 공통 UI 컴포넌트 (Button, Input 등)
|
||||
│ ├── layout/ # 레이아웃 컴포넌트 (Header, Sidebar)
|
||||
│ └── domain/ # 도메인 특화 컴포넌트
|
||||
├── pages/ # 페이지 컴포넌트
|
||||
│ ├── Dashboard/
|
||||
│ ├── TaxReturn/
|
||||
│ └── Settings/
|
||||
├── hooks/ # 커스텀 React Hooks
|
||||
├── services/ # API 호출 로직
|
||||
├── stores/ # 상태 관리 (Zustand)
|
||||
├── types/ # TypeScript 타입 정의
|
||||
├── utils/ # 유틸리티 함수
|
||||
└── assets/ # 정적 파일 (이미지, 폰트)</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>주요 컴포넌트</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg> DashboardWidget</h4><p>대시보드 KPI 카드 및 차트</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></svg> TaxReturnWizard</h4><p>단계별 신고서 작성 마법사</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /></svg> ReceiptUploader</h4><p>증빙 파일 업로드 및 OCR</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" /></svg> TaxChart</h4><p>세무 데이터 시각화</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" /></svg> NotificationCenter</h4><p>알림 및 작업 센터</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.02 48.02 0 0112 21c-2.773 0-5.491-.235-8.135-.698-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" /></svg> AIAssistant</h4><p>채팅 기반 세무 도우미</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>성능 최적화 전략</h3>
|
||||
<ul>
|
||||
<li><strong>Code Splitting:</strong> 페이지 단위 lazy loading으로 초기 로딩 속도 개선</li>
|
||||
<li><strong>Memoization:</strong> React.memo, useMemo, useCallback 활용</li>
|
||||
<li><strong>Virtual Scrolling:</strong> 대용량 테이블 렌더링 최적화</li>
|
||||
<li><strong>이미지 최적화:</strong> WebP 포맷, lazy loading, responsive images</li>
|
||||
<li><strong>캐싱:</strong> React Query로 서버 데이터 캐싱 및 재검증</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 17: Development Roadmap -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>개발 로드맵</h2>
|
||||
<table class="pricing-table">
|
||||
<thead><tr><th>단계</th><th>기간</th><th>주요 마일스톤</th><th>산출물</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Phase 1: 기획</strong></td><td>4주</td><td>요구사항 정의, 시스템 설계</td><td>PRD, 아키텍처 문서</td></tr>
|
||||
<tr><td><strong>Phase 2: 디자인</strong></td><td>4주</td><td>UI/UX 설계, 디자인 시스템 구축</td><td>Figma 프로토타입, 디자인 가이드</td></tr>
|
||||
<tr><td><strong>Phase 3: MVP 개발</strong></td><td>12주</td><td>핵심 기능 구현 (신고, 증빙, 대시보드)</td><td>Alpha 버전</td></tr>
|
||||
<tr><td><strong>Phase 4: 통합 테스트</strong></td><td>4주</td><td>외부 시스템 연동 및 테스트</td><td>Beta 버전</td></tr>
|
||||
<tr><td><strong>Phase 5: 파일럿</strong></td><td>4주</td><td>실사용자 테스트 및 피드백 수집</td><td>개선사항 리스트</td></tr>
|
||||
<tr><td><strong>Phase 6: 정식 출시</strong></td><td>2주</td><td>버그 수정 및 성능 최적화</td><td>v1.0 릴리스</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="scenario-box blue" style="margin-top: 30px;">
|
||||
<h3>주요 기술 과제</h3>
|
||||
<ul>
|
||||
<li><strong>세법 규칙 엔진:</strong> 복잡한 세법 규칙의 유연한 모델링</li>
|
||||
<li><strong>대용량 데이터 처리:</strong> 수백만 건의 거래 데이터 실시간 처리</li>
|
||||
<li><strong>외부 시스템 통합:</strong> 다양한 ERP/회계 시스템 API 연동</li>
|
||||
<li><strong>보안 및 컴플라이언스:</strong> 금융권 수준의 보안 요구사항 충족</li>
|
||||
<li><strong>AI 모델 학습:</strong> 세무 데이터 기반 예측 모델 개발</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="scenario-box amber" style="margin-top: 20px;">
|
||||
<h3 style="color: #92400e;">예상 리소스</h3>
|
||||
<ul style="color: #78350f;">
|
||||
<li><strong>기획:</strong> PM 1명, 세무 도메인 전문가 1명</li>
|
||||
<li><strong>디자인:</strong> UI/UX 디자이너 2명</li>
|
||||
<li><strong>백엔드:</strong> 시니어 개발자 3명, 주니어 개발자 2명</li>
|
||||
<li><strong>프론트엔드:</strong> 시니어 개발자 2명, 주니어 개발자 2명</li>
|
||||
<li><strong>QA:</strong> 테스터 2명</li>
|
||||
<li><strong>DevOps:</strong> 인프라 엔지니어 1명</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 18: Success Metrics -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>성공 지표</h2>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>📊 정량적 KPI</h3>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>지표</th><th>목표치</th><th>측정 방법</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>업무 시간 절감</strong></td><td>80% 이상</td><td>신고 준비 소요 시간 Before/After 비교</td></tr>
|
||||
<tr><td><strong>오류율 감소</strong></td><td>95% 이상</td><td>수정 신고 건수 감소율</td></tr>
|
||||
<tr><td><strong>사용자 만족도</strong></td><td>NPS 50 이상</td><td>분기별 사용자 설문 조사</td></tr>
|
||||
<tr><td><strong>시스템 가용성</strong></td><td>99.9% 이상</td><td>Uptime 모니터링</td></tr>
|
||||
<tr><td><strong>API 응답 속도</strong></td><td>평균 200ms 이하</td><td>APM 도구 (New Relic/Datadog)</td></tr>
|
||||
<tr><td><strong>월간 활성 사용자</strong></td><td>80% 이상</td><td>Google Analytics / Mixpanel</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>🎯 정성적 목표</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg> 혁신성</h4><p>국내 최초 AI 기반 세무 자동화 플랫폼 구축</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 002.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 012.916.52 6.003 6.003 0 01-5.395 4.972m0 0a6.726 6.726 0 01-2.749 1.35m0 0a6.772 6.772 0 01-3.044 0" /></svg> 시장 포지션</h4><p>중소기업 세무 솔루션 시장 점유율 Top 3</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /></svg> 파트너십</h4><p>주요 ERP/회계 시스템과 공식 파트너십 체결</p></div>
|
||||
<div class="feature-item"><h4 class="flex items-center gap-2"><svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg> 보안 인증</h4><p>ISO 27001, SOC 2 Type 2 인증 획득</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 19: Risk & Mitigation -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>리스크 관리</h2>
|
||||
<table class="comparison-table">
|
||||
<thead><tr><th>리스크</th><th>영향</th><th>대응 전략</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>세법 변경</strong></td><td>높음</td><td>규칙 엔진 기반 설계로 신속 대응, 법률 자문 확보</td></tr>
|
||||
<tr><td><strong>외부 시스템 장애</strong></td><td>중간</td><td>Fallback 메커니즘, 수동 입력 옵션 제공</td></tr>
|
||||
<tr><td><strong>데이터 보안 침해</strong></td><td>매우 높음</td><td>다층 보안 체계, 정기 보안 감사, 침해 대응 계획 수립</td></tr>
|
||||
<tr><td><strong>개발 일정 지연</strong></td><td>중간</td><td>Agile 방법론, 단계별 마일스톤 관리</td></tr>
|
||||
<tr><td><strong>사용자 채택률 저조</strong></td><td>높음</td><td>직관적 UX, 충분한 교육 및 온보딩, 전담 지원팀</td></tr>
|
||||
<tr><td><strong>AI 모델 정확도</strong></td><td>중간</td><td>Human-in-the-loop 검증, 점진적 자동화 확대</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="scenario-box amber" style="margin-top: 30px;">
|
||||
<h3 style="color: #92400e;">규제 준수 전략</h3>
|
||||
<ul style="color: #78350f;">
|
||||
<li><strong>전자세금계산서법:</strong> 국세청 표준 XML 스키마 준수</li>
|
||||
<li><strong>개인정보보호법:</strong> PIMS 인증 획득, 개인정보 암호화 저장</li>
|
||||
<li><strong>전자금융거래법:</strong> 전자서명 및 공인인증서 연동</li>
|
||||
<li><strong>회계 기준:</strong> K-IFRS 및 일반기업회계기준 준수</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 20: Conclusion -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h2>맺음말</h2>
|
||||
<div class="conclusion-box">
|
||||
<h3 style="color: white; margin-top: 0;">세무 업무의 새로운 패러다임</h3>
|
||||
<p style="font-size: 1.3em;">자동화로 시간을 절약하고, AI로 인사이트를 발견하며, 통합으로 효율을 극대화하는 차세대 세무 솔루션</p>
|
||||
</div>
|
||||
<div style="margin-top: 40px;">
|
||||
<h3>핵심 차별점</h3>
|
||||
<div class="company-info">
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg> 완전 자동화</h4><p>데이터 수집부터 신고까지 End-to-End 자동화</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.02 48.02 0 0112 21c-2.773 0-5.491-.235-8.135-.698-1.717-.293-2.3-2.379-1.067-3.61L5 14.5" /></svg> AI 기반</h4><p>머신러닝으로 패턴 학습 및 절세 전략 제안</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg> 완벽한 통합</h4><p>모든 주요 ERP/회계 시스템 네이티브 연동</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /></svg> 언제 어디서나</h4><p>클라우드 기반으로 시간과 장소의 제약 없음</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg> 기업급 보안</h4><p>금융권 수준의 다층 보안 체계</p></div>
|
||||
<div class="info-card"><h4 class="flex items-center gap-2"><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 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg> 직관적 UX</h4><p>복잡한 세무를 누구나 쉽게 처리</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 40px; text-align: center;">
|
||||
<p style="font-size: 1.3em; color: #2563eb; font-weight: 700;"><strong>예상 효과</strong></p>
|
||||
<p style="font-size: 1.1em; margin-top: 20px;">업무 시간 <span class="highlight">80% 절감</span> | 오류율 <span class="highlight">95% 감소</span> | 절세 기회 <span class="highlight">자동 발견</span></p>
|
||||
</div>
|
||||
<div class="info-box" style="margin-top: 40px;">
|
||||
<h3 style="text-align: center;">Next Steps</h3>
|
||||
<ol style="font-size: 1.1em; margin-top: 20px;">
|
||||
<li>이해관계자 승인 및 예산 확보</li>
|
||||
<li>개발팀 구성 및 킥오프 미팅</li>
|
||||
<li>Phase 1 상세 요구사항 정의 착수</li>
|
||||
<li>디자인 시스템 프로토타입 개발</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 21: Thank You -->
|
||||
<div class="slide">
|
||||
<div class="slide-content">
|
||||
<h1 style="font-size: 4em; margin-bottom: 40px;">감사합니다</h1>
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 1.5em; color: #2563eb; margin-bottom: 30px; font-weight: 700;">장기적 세무전략 - 중장기 계획안</p>
|
||||
<div style="margin-top: 50px; padding: 30px; background: #f1f5f9; border-radius: 16px; border: 1px solid #e2e8f0;">
|
||||
<h3>문의 및 피드백</h3>
|
||||
<p style="margin-top: 20px;">본 계획안에 대한 의견이나 추가 논의가 필요하신 경우</p>
|
||||
<p>프로젝트 팀으로 연락 주시기 바랍니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide-number">
|
||||
<span id="currentSlide">1</span> / <span id="totalSlides">21</span>
|
||||
</div>
|
||||
|
||||
<div class="navigation">
|
||||
<button class="nav-btn" id="prevBtn" onclick="changeSlide(-1)">← 이전</button>
|
||||
<button class="nav-btn" id="nextBtn" onclick="changeSlide(1)">다음 →</button>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let currentSlide = 1;
|
||||
const totalSlides = 21;
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
|
||||
document.getElementById('totalSlides').textContent = totalSlides;
|
||||
|
||||
function showSlide(n) {
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
|
||||
if (n > totalSlides) currentSlide = 1;
|
||||
if (n < 1) currentSlide = totalSlides;
|
||||
|
||||
slides.forEach(slide => slide.classList.remove('active'));
|
||||
|
||||
if (slides[currentSlide - 1]) {
|
||||
slides[currentSlide - 1].classList.add('active');
|
||||
}
|
||||
document.getElementById('currentSlide').textContent = currentSlide;
|
||||
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
if (prevBtn) prevBtn.disabled = (currentSlide === 1);
|
||||
if (nextBtn) nextBtn.disabled = (currentSlide === totalSlides);
|
||||
}
|
||||
|
||||
function changeSlide(direction) {
|
||||
currentSlide += direction;
|
||||
showSlide(currentSlide);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
changeSlide(-1);
|
||||
} else if (event.key === 'ArrowRight' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
changeSlide(1);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchstart', function(event) {
|
||||
touchStartX = event.changedTouches[0].screenX;
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', function(event) {
|
||||
touchEndX = event.changedTouches[0].screenX;
|
||||
handleSwipe();
|
||||
});
|
||||
|
||||
function handleSwipe() {
|
||||
if (touchEndX < touchStartX - 50) changeSlide(1);
|
||||
if (touchEndX > touchStartX + 50) changeSlide(-1);
|
||||
}
|
||||
|
||||
showSlide(currentSlide);
|
||||
</script>
|
||||
@endpush
|
||||
@@ -120,6 +120,8 @@
|
||||
})();
|
||||
</script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<!-- HTMX 전역 설정 (HTMX 로드 직후 등록하여 load 트리거보다 먼저 실행) -->
|
||||
<script>
|
||||
// HTMX 401 에러 핸들러 (세션 만료 시 자동 갱신 또는 로그인 페이지로 이동)
|
||||
|
||||
@@ -387,23 +387,9 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
|
||||
<!-- 탭 + 메뉴 콘텐츠 -->
|
||||
<div id="lab-group" class="mt-2">
|
||||
<!-- S | A 탭 버튼 -->
|
||||
<div class="lab-tabs flex mx-2 mb-2 bg-gray-100 rounded-lg p-1">
|
||||
<button type="button" onclick="switchLabTab('s')" id="lab-tab-s" class="lab-tab active flex-1 py-1.5 text-xs font-bold rounded-md transition-all text-blue-600">
|
||||
S
|
||||
</button>
|
||||
<button type="button" onclick="switchLabTab('a')" id="lab-tab-a" class="lab-tab flex-1 py-1.5 text-xs font-bold rounded-md transition-all text-purple-600">
|
||||
A
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- S. Strategy 메뉴 (16개) -->
|
||||
<!-- S. Strategy 메뉴 -->
|
||||
<ul id="lab-panel-s" class="lab-panel space-y-1">
|
||||
<li><a href="{{ route('lab.strategy.tax') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="세무 전략"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg><span class="font-medium sidebar-text">세무 전략</span></a></li>
|
||||
<li><a href="{{ route('lab.strategy.labor') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="노무 전략"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg><span class="font-medium sidebar-text">노무 전략</span></a></li>
|
||||
<li><a href="{{ route('lab.strategy.debt') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="채권추심 전략"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span class="font-medium sidebar-text">채권추심 전략</span></a></li>
|
||||
<li class="border-t border-gray-100 pt-1 mt-1"></li>
|
||||
<li><a href="{{ route('lab.strategy.mrp-overseas') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="MRP 해외사례"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span class="font-medium sidebar-text">MRP 해외사례</span></a></li>
|
||||
<li><a href="{{ route('lab.strategy.chatbot') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="상담용 챗봇 전략"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg><span class="font-medium sidebar-text">상담용 챗봇 전략</span></a></li>
|
||||
<li><a href="{{ route('lab.strategy.knowledge-search') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="사내 지식 검색 시스템"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg><span class="font-medium sidebar-text">사내 지식 검색 시스템</span></a></li>
|
||||
<li><a href="{{ route('lab.strategy.chatbot-compare') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="챗봇 솔루션 비교 분석"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg><span class="font-medium sidebar-text">챗봇 솔루션 비교 분석</span></a></li>
|
||||
@@ -412,18 +398,6 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
<li><a href="{{ route('lab.strategy.confluence-vs-notion') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="Confluence vs Notion"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" /></svg><span class="font-medium sidebar-text">Confluence vs Notion</span></a></li>
|
||||
<li><a href="{{ route('lab.strategy.sales-strategy') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="SAM 영업전략"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg><span class="font-medium sidebar-text">SAM 영업전략</span></a></li>
|
||||
</ul>
|
||||
|
||||
<!-- A. AI/Automation 메뉴 -->
|
||||
<ul id="lab-panel-a" class="lab-panel space-y-1 hidden">
|
||||
<li><a href="{{ route('lab.ai.web-recording') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="웹 녹음 AI 요약"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg><span class="font-medium sidebar-text">웹 녹음 AI 요약</span></a></li>
|
||||
<li><a href="{{ route('lab.ai.meeting-summary') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="회의록 AI 요약"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg><span class="font-medium sidebar-text">회의록 AI 요약</span></a></li>
|
||||
<li><a href="{{ route('lab.ai.work-memo-summary') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="업무협의록 AI 요약"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg><span class="font-medium sidebar-text">업무협의록 AI 요약</span></a></li>
|
||||
<li class="border-t border-gray-100 pt-1 mt-1"></li>
|
||||
<li><a href="{{ route('lab.ai.operator-chatbot') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="운영자용 챗봇"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg><span class="font-medium sidebar-text">운영자용 챗봇</span></a></li>
|
||||
<li><a href="{{ route('lab.ai.vertex-rag') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="Vertex RAG 챗봇"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg><span class="font-medium sidebar-text">Vertex RAG 챗봇</span></a></li>
|
||||
<li><a href="{{ route('lab.ai.tenant-knowledge') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="테넌트 지식 업로드"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg><span class="font-medium sidebar-text">테넌트 지식 업로드</span></a></li>
|
||||
<li><a href="{{ route('lab.ai.tenant-chatbot') }}" class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="테넌트 챗봇"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" /></svg><span class="font-medium sidebar-text">테넌트 챗봇</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -447,25 +421,11 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
<span class="text-xs font-bold text-amber-800 uppercase tracking-wider">R&D Labs</span>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="flex p-2 bg-gray-50 border-b border-gray-100">
|
||||
<button type="button" onclick="switchLabFlyoutTab('s')" id="lab-flyout-tab-s" class="lab-flyout-tab active flex-1 py-1.5 text-xs font-bold rounded transition-all text-blue-600">
|
||||
S
|
||||
</button>
|
||||
<button type="button" onclick="switchLabFlyoutTab('a')" id="lab-flyout-tab-a" class="lab-flyout-tab flex-1 py-1.5 text-xs font-bold rounded transition-all text-purple-600">
|
||||
A
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 패널들 -->
|
||||
<div class="p-2 max-h-64 overflow-y-auto">
|
||||
<!-- S. Strategy (15개) -->
|
||||
<!-- S. Strategy -->
|
||||
<ul id="lab-flyout-panel-s" class="lab-flyout-panel space-y-0.5">
|
||||
<li><a href="{{ route('lab.strategy.tax') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">세무 전략</a></li>
|
||||
<li><a href="{{ route('lab.strategy.labor') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">노무 전략</a></li>
|
||||
<li><a href="{{ route('lab.strategy.debt') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">채권추심 전략</a></li>
|
||||
<li class="border-t border-gray-100 my-1"></li>
|
||||
<li><a href="{{ route('lab.strategy.mrp-overseas') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">MRP 해외사례</a></li>
|
||||
<li><a href="{{ route('lab.strategy.chatbot') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">상담용 챗봇 전략</a></li>
|
||||
<li><a href="{{ route('lab.strategy.knowledge-search') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">사내 지식 검색 시스템</a></li>
|
||||
<li><a href="{{ route('lab.strategy.chatbot-compare') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">챗봇 솔루션 비교 분석</a></li>
|
||||
@@ -474,17 +434,6 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
<li><a href="{{ route('lab.strategy.confluence-vs-notion') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">Confluence vs Notion</a></li>
|
||||
<li><a href="{{ route('lab.strategy.sales-strategy') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">SAM 영업전략</a></li>
|
||||
</ul>
|
||||
<!-- A. AI/Automation (12개) -->
|
||||
<ul id="lab-flyout-panel-a" class="lab-flyout-panel space-y-0.5 hidden">
|
||||
<li><a href="{{ route('lab.ai.web-recording') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">웹 녹음 AI 요약</a></li>
|
||||
<li><a href="{{ route('lab.ai.meeting-summary') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">회의록 AI 요약</a></li>
|
||||
<li><a href="{{ route('lab.ai.work-memo-summary') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">업무협의록 AI 요약</a></li>
|
||||
<li class="border-t border-gray-100 my-1"></li>
|
||||
<li><a href="{{ route('lab.ai.operator-chatbot') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">운영자용 챗봇</a></li>
|
||||
<li><a href="{{ route('lab.ai.vertex-rag') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">Vertex RAG 챗봇</a></li>
|
||||
<li><a href="{{ route('lab.ai.tenant-knowledge') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">테넌트 지식 업로드</a></li>
|
||||
<li><a href="{{ route('lab.ai.tenant-chatbot') }}" class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:text-gray-900">테넌트 챗봇</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo / Brand -->
|
||||
<div class="flex items-center h-16 border-b border-gray-200 px-3">
|
||||
<!-- 펼쳐진 상태: 햄버거 버튼 + 로고 -->
|
||||
<!-- 펼쳐진 상태: 햄버거 버튼 + 로고 + 검색 -->
|
||||
<div class="sidebar-expanded-only flex items-center gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
@@ -18,7 +18,19 @@ class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transi
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xl font-bold text-gray-900">{{ config('app.name') }}</span>
|
||||
<span class="text-xl font-bold text-gray-900 flex-1">{{ config('app.name') }}</span>
|
||||
<!-- 검색 아이콘 -->
|
||||
<button
|
||||
type="button"
|
||||
onclick="openMenuSearch()"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="메뉴 검색"
|
||||
id="menu-search-btn"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 접힌 상태: S 버튼 (클릭하면 확장) -->
|
||||
<button
|
||||
@@ -31,6 +43,37 @@ class="sidebar-collapsed-only hidden w-full p-2 text-xl font-bold text-gray-900
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 검색창 (숨김 상태) -->
|
||||
<div id="menu-search-container" class="hidden border-b border-gray-200 px-3 py-2 bg-gray-50">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="menu-search-input"
|
||||
placeholder="메뉴 검색..."
|
||||
class="w-full pl-10 pr-8 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
oninput="filterMenus(this.value)"
|
||||
autocomplete="off"
|
||||
>
|
||||
<!-- 검색 아이콘 -->
|
||||
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<!-- 닫기 버튼 -->
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeMenuSearch()"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
title="검색 닫기"
|
||||
>
|
||||
<svg class="w-4 h-4" 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 id="menu-search-info" class="hidden mt-2 text-xs text-gray-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 전용: 테넌트 셀렉터 (lg 미만에서만 표시) -->
|
||||
<div class="lg:hidden border-b border-gray-200 p-3 bg-gray-50">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
@@ -93,6 +136,45 @@ class="w-full border-gray-300 rounded-lg text-sm focus:ring-primary focus:border
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* ========== 메뉴 검색 스타일 ========== */
|
||||
.menu-search-highlight {
|
||||
background-color: #fef08a;
|
||||
color: #854d0e;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-search-match > a {
|
||||
background-color: #f0fdf4 !important;
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
#menu-search-container {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
#menu-search-container.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 사이드바 접힌 상태에서 검색창 숨김 */
|
||||
html.sidebar-is-collapsed #sidebar #menu-search-container,
|
||||
.sidebar.sidebar-collapsed #menu-search-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 초기 로드 시 transition 비활성화 (깜빡임 방지) ========== */
|
||||
body.no-transition .sidebar,
|
||||
body.no-transition .sidebar *,
|
||||
@@ -926,4 +1008,294 @@ function initSidebarTooltips() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 메뉴 검색 기능 ==========
|
||||
|
||||
let menuSearchActive = false;
|
||||
let originalMenuState = null;
|
||||
|
||||
// 검색창 열기
|
||||
function openMenuSearch() {
|
||||
const container = document.getElementById('menu-search-container');
|
||||
const input = document.getElementById('menu-search-input');
|
||||
const searchBtn = document.getElementById('menu-search-btn');
|
||||
|
||||
if (!container || !input) return;
|
||||
|
||||
// 원래 메뉴 상태 저장
|
||||
if (!menuSearchActive) {
|
||||
saveOriginalMenuState();
|
||||
}
|
||||
|
||||
menuSearchActive = true;
|
||||
container.classList.remove('hidden');
|
||||
searchBtn.classList.add('hidden');
|
||||
|
||||
// localStorage에 검색 활성화 상태 저장
|
||||
localStorage.setItem('menu-search-active', 'true');
|
||||
|
||||
// 포커스 및 애니메이션
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 검색창 닫기
|
||||
function closeMenuSearch() {
|
||||
const container = document.getElementById('menu-search-container');
|
||||
const input = document.getElementById('menu-search-input');
|
||||
const searchBtn = document.getElementById('menu-search-btn');
|
||||
const info = document.getElementById('menu-search-info');
|
||||
|
||||
if (!container || !input) return;
|
||||
|
||||
menuSearchActive = false;
|
||||
container.classList.add('hidden');
|
||||
searchBtn.classList.remove('hidden');
|
||||
input.value = '';
|
||||
info.classList.add('hidden');
|
||||
|
||||
// localStorage에서 검색 상태 제거
|
||||
localStorage.removeItem('menu-search-active');
|
||||
localStorage.removeItem('menu-search-query');
|
||||
|
||||
// 원래 메뉴 상태 복원
|
||||
restoreOriginalMenuState();
|
||||
}
|
||||
|
||||
// 원래 메뉴 상태 저장
|
||||
function saveOriginalMenuState() {
|
||||
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||
if (!sidebarNav) return;
|
||||
|
||||
originalMenuState = {
|
||||
items: []
|
||||
};
|
||||
|
||||
// 모든 메뉴 아이템과 그룹의 표시 상태 저장
|
||||
sidebarNav.querySelectorAll('li, [id^="menu-group-"], #lab-group').forEach(el => {
|
||||
originalMenuState.items.push({
|
||||
element: el,
|
||||
display: el.style.display,
|
||||
classList: [...el.classList]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 원래 메뉴 상태 복원 (또는 전체 메뉴 표시)
|
||||
function restoreOriginalMenuState() {
|
||||
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||
|
||||
if (originalMenuState) {
|
||||
// 저장된 상태가 있으면 복원
|
||||
originalMenuState.items.forEach(item => {
|
||||
item.element.style.display = item.display || '';
|
||||
item.element.classList.remove('menu-search-hidden', 'menu-search-match');
|
||||
});
|
||||
originalMenuState = null;
|
||||
} else if (sidebarNav) {
|
||||
// 저장된 상태가 없으면 모든 메뉴 표시 (새로고침 후 닫기 시)
|
||||
sidebarNav.querySelectorAll('li').forEach(li => {
|
||||
li.style.display = '';
|
||||
li.classList.remove('menu-search-hidden', 'menu-search-match');
|
||||
});
|
||||
sidebarNav.querySelectorAll('[id^="menu-group-"], #lab-group').forEach(group => {
|
||||
group.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
// 검색 하이라이트 제거
|
||||
document.querySelectorAll('.menu-search-highlight').forEach(el => {
|
||||
const parent = el.parentNode;
|
||||
parent.replaceChild(document.createTextNode(el.textContent), el);
|
||||
parent.normalize();
|
||||
});
|
||||
}
|
||||
|
||||
// 메뉴 필터링 (실시간 검색)
|
||||
function filterMenus(query, skipSave) {
|
||||
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||
const info = document.getElementById('menu-search-info');
|
||||
|
||||
if (!sidebarNav) return;
|
||||
|
||||
query = query.trim().toLowerCase();
|
||||
|
||||
// localStorage에 검색어 저장 (복원 시에는 저장 안 함)
|
||||
if (!skipSave) {
|
||||
if (query) {
|
||||
localStorage.setItem('menu-search-query', query);
|
||||
} else {
|
||||
localStorage.removeItem('menu-search-query');
|
||||
}
|
||||
}
|
||||
|
||||
// 검색어가 비어있으면 모든 메뉴 표시
|
||||
if (!query) {
|
||||
sidebarNav.querySelectorAll('li').forEach(li => {
|
||||
li.style.display = '';
|
||||
li.classList.remove('menu-search-hidden', 'menu-search-match');
|
||||
});
|
||||
sidebarNav.querySelectorAll('[id^="menu-group-"], #lab-group').forEach(group => {
|
||||
group.style.display = '';
|
||||
});
|
||||
// 하이라이트 제거
|
||||
document.querySelectorAll('.menu-search-highlight').forEach(el => {
|
||||
const parent = el.parentNode;
|
||||
parent.replaceChild(document.createTextNode(el.textContent), el);
|
||||
parent.normalize();
|
||||
});
|
||||
info.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let matchCount = 0;
|
||||
|
||||
// 모든 메뉴 아이템 순회
|
||||
const menuItems = sidebarNav.querySelectorAll('li');
|
||||
|
||||
menuItems.forEach(li => {
|
||||
const link = li.querySelector('a[href], span[title]');
|
||||
if (!link) {
|
||||
// 그룹 헤더 등은 일단 숨김
|
||||
li.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const menuName = link.textContent.trim().toLowerCase();
|
||||
const title = (link.getAttribute('title') || '').toLowerCase();
|
||||
|
||||
if (menuName.includes(query) || title.includes(query)) {
|
||||
li.style.display = '';
|
||||
li.classList.add('menu-search-match');
|
||||
li.classList.remove('menu-search-hidden');
|
||||
matchCount++;
|
||||
|
||||
// 하이라이트 적용
|
||||
highlightText(link, query);
|
||||
|
||||
// 부모 그룹들도 표시
|
||||
showParentGroups(li);
|
||||
} else {
|
||||
li.style.display = 'none';
|
||||
li.classList.add('menu-search-hidden');
|
||||
li.classList.remove('menu-search-match');
|
||||
}
|
||||
});
|
||||
|
||||
// 검색 결과 정보 표시
|
||||
if (matchCount > 0) {
|
||||
info.textContent = `${matchCount}개 메뉴 발견`;
|
||||
info.classList.remove('hidden');
|
||||
} else {
|
||||
info.textContent = '검색 결과가 없습니다';
|
||||
info.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 모든 그룹 펼치기
|
||||
sidebarNav.querySelectorAll('[id^="menu-group-"], #lab-group').forEach(group => {
|
||||
if (group.querySelector('.menu-search-match')) {
|
||||
group.style.display = 'block';
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 부모 그룹 표시
|
||||
function showParentGroups(element) {
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
if (parent.id && (parent.id.startsWith('menu-group-') || parent.id === 'lab-group')) {
|
||||
parent.style.display = 'block';
|
||||
}
|
||||
if (parent.tagName === 'LI') {
|
||||
parent.style.display = '';
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
if (parent && parent.classList && parent.classList.contains('sidebar-nav')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 하이라이트
|
||||
function highlightText(element, query) {
|
||||
const textSpan = element.querySelector('.sidebar-text');
|
||||
if (!textSpan) return;
|
||||
|
||||
// 기존 하이라이트 제거
|
||||
const existingHighlights = textSpan.querySelectorAll('.menu-search-highlight');
|
||||
existingHighlights.forEach(el => {
|
||||
const parent = el.parentNode;
|
||||
parent.replaceChild(document.createTextNode(el.textContent), el);
|
||||
parent.normalize();
|
||||
});
|
||||
|
||||
const text = textSpan.textContent;
|
||||
const lowerText = text.toLowerCase();
|
||||
const index = lowerText.indexOf(query);
|
||||
|
||||
if (index >= 0) {
|
||||
const before = text.substring(0, index);
|
||||
const match = text.substring(index, index + query.length);
|
||||
const after = text.substring(index + query.length);
|
||||
|
||||
textSpan.innerHTML = '';
|
||||
textSpan.appendChild(document.createTextNode(before));
|
||||
|
||||
const highlight = document.createElement('span');
|
||||
highlight.className = 'menu-search-highlight';
|
||||
highlight.textContent = match;
|
||||
textSpan.appendChild(highlight);
|
||||
|
||||
textSpan.appendChild(document.createTextNode(after));
|
||||
}
|
||||
}
|
||||
|
||||
// ESC 키로 검색 닫기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && menuSearchActive) {
|
||||
closeMenuSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Ctrl+K 또는 Cmd+K로 검색 열기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (menuSearchActive) {
|
||||
closeMenuSearch();
|
||||
} else {
|
||||
openMenuSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 검색 상태 복원
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedSearchActive = localStorage.getItem('menu-search-active');
|
||||
const savedSearchQuery = localStorage.getItem('menu-search-query');
|
||||
|
||||
if (savedSearchActive === 'true') {
|
||||
const container = document.getElementById('menu-search-container');
|
||||
const input = document.getElementById('menu-search-input');
|
||||
const searchBtn = document.getElementById('menu-search-btn');
|
||||
|
||||
if (container && input) {
|
||||
menuSearchActive = true;
|
||||
container.classList.remove('hidden');
|
||||
searchBtn.classList.add('hidden');
|
||||
|
||||
// 저장된 검색어가 있으면 복원 및 필터링
|
||||
if (savedSearchQuery) {
|
||||
input.value = savedSearchQuery;
|
||||
// 약간의 딜레이 후 필터링 (DOM 완전 로드 대기)
|
||||
setTimeout(() => {
|
||||
filterMenus(savedSearchQuery, true);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -43,4 +43,30 @@
|
||||
@include('sales.dashboard.partials.data-container')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 시나리오 모달용 포털 --}}
|
||||
<div id="modal-portal"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Alpine.js x-collapse 플러그인이 없는 경우를 위한 폴백
|
||||
if (typeof Alpine !== 'undefined' && !Alpine.directive('collapse')) {
|
||||
Alpine.directive('collapse', (el, { expression }, { effect, evaluateLater }) => {
|
||||
let isOpen = evaluateLater(expression);
|
||||
|
||||
effect(() => {
|
||||
isOpen((value) => {
|
||||
if (value) {
|
||||
el.style.height = 'auto';
|
||||
el.style.overflow = 'visible';
|
||||
} else {
|
||||
el.style.height = '0px';
|
||||
el.style.overflow = 'hidden';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{{-- 대시보드 데이터 컨테이너 (HTMX로 새로고침되는 영역) --}}
|
||||
|
||||
{{-- 영업파트너 수당 현황 (파트너인 경우에만 표시) --}}
|
||||
@if (isset($partner) && $partner)
|
||||
@include('sales.dashboard.partials.my-commission')
|
||||
@endif
|
||||
|
||||
{{-- 전체 누적 실적 --}}
|
||||
@include('sales.dashboard.partials.stats')
|
||||
|
||||
@@ -72,14 +77,9 @@ class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 역할별 수당 상세 --}}
|
||||
@include('sales.dashboard.partials.commission-by-role')
|
||||
|
||||
{{-- 실적 데이터 없음 안내 --}}
|
||||
@include('sales.dashboard.partials.no-data')
|
||||
|
||||
{{-- 수익 및 테넌트 관리 --}}
|
||||
@include('sales.dashboard.partials.tenant-stats')
|
||||
{{-- 내 계약 현황 --}}
|
||||
@include('sales.dashboard.partials.tenant-list')
|
||||
|
||||
<script>
|
||||
function toggleCustomPeriod() {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
{{-- 매니저 드롭다운 컴포넌트 --}}
|
||||
@once
|
||||
<style>[x-cloak] { display: none !important; }</style>
|
||||
@endonce
|
||||
@php
|
||||
$management = $managements[$tenant->id] ?? null;
|
||||
$assignedManager = $management?->manager;
|
||||
$isSelf = !$assignedManager || $assignedManager->id === auth()->id();
|
||||
$managerName = $assignedManager?->name ?? '본인';
|
||||
$managersJson = $allManagers->map(fn($m) => ['id' => $m->id, 'name' => $m->name, 'email' => $m->email])->values()->toJson();
|
||||
$currentManagerJson = json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'is_self' => $isSelf] : null);
|
||||
@endphp
|
||||
|
||||
<div x-data="{
|
||||
tenantId: {{ $tenant->id }},
|
||||
isOpen: false,
|
||||
managers: {{ $managersJson }},
|
||||
currentManager: {{ $currentManagerJson }},
|
||||
toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
},
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
selectManager(managerId, managerName) {
|
||||
fetch('/sales/tenants/' + this.tenantId + '/assign-manager', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
},
|
||||
body: JSON.stringify({ manager_id: managerId }),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
this.currentManager = {
|
||||
id: result.manager.id,
|
||||
name: result.manager.name,
|
||||
is_self: managerId === 0 || result.manager.id === {{ auth()->id() }},
|
||||
};
|
||||
} else {
|
||||
alert(result.message || '매니저 지정에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('매니저 지정 실패:', error);
|
||||
alert('매니저 지정에 실패했습니다.');
|
||||
});
|
||||
this.close();
|
||||
}
|
||||
}" class="relative">
|
||||
{{-- 드롭다운 트리거 --}}
|
||||
<button
|
||||
x-on:click="toggle()"
|
||||
x-on:click.outside="close()"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium transition-colors"
|
||||
:class="isOpen ? 'bg-blue-100 text-blue-800 border border-blue-300' : 'bg-blue-50 text-blue-700 border border-blue-200 hover:bg-blue-100'">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>관리: <span x-text="currentManager?.name || '본인'" class="font-semibold">{{ $managerName }}</span></span>
|
||||
<svg class="w-3 h-3 transition-transform" :class="isOpen && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 드롭다운 메뉴 --}}
|
||||
<div
|
||||
x-cloak
|
||||
x-show="isOpen"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-1 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
|
||||
x-on:click.stop
|
||||
>
|
||||
{{-- 본인 옵션 --}}
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="selectManager(0, '{{ auth()->user()->name }}')"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors"
|
||||
:class="(currentManager?.is_self || !currentManager) && 'bg-blue-50'">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-600" 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>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">본인</div>
|
||||
<div class="text-xs text-gray-500">{{ auth()->user()->name }}</div>
|
||||
</div>
|
||||
<svg x-show="currentManager?.is_self || !currentManager" class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 구분선 (다른 매니저가 있을 때만) --}}
|
||||
<template x-if="managers.length > 0">
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
</template>
|
||||
|
||||
{{-- 다른 매니저 목록 --}}
|
||||
<template x-for="manager in managers" :key="manager.id">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="selectManager(manager.id, manager.name)"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors"
|
||||
:class="currentManager?.id === manager.id && !currentManager?.is_self && 'bg-blue-50'">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-gray-600" x-text="manager.name.charAt(0)"></span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900" x-text="manager.name"></div>
|
||||
<div class="text-xs text-gray-500" x-text="manager.email"></div>
|
||||
</div>
|
||||
<svg x-show="currentManager?.id === manager.id && !currentManager?.is_self" class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
{{-- 매니저가 없을 때 --}}
|
||||
<template x-if="managers.length === 0">
|
||||
<div class="px-4 py-3 text-sm text-gray-500 text-center border-t border-gray-100">
|
||||
등록된 매니저가 없습니다.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
{{-- 영업파트너 수당 현황 카드 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">내 수당 현황</h3>
|
||||
<a href="{{ route('finance.sales-commissions.index') }}"
|
||||
class="text-sm text-emerald-600 hover:text-emerald-700">
|
||||
전체보기 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
{{-- 수당 요약 --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{{-- 이번 달 지급예정 --}}
|
||||
<div class="bg-emerald-50 rounded-lg p-4">
|
||||
<div class="text-sm text-emerald-600 mb-1">이번 달 지급예정</div>
|
||||
<div class="text-xl font-bold text-emerald-700">
|
||||
{{ number_format($commissionSummary['scheduled_this_month'] ?? 0) }}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 누적 수령액 --}}
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="text-sm text-blue-600 mb-1">누적 수령액</div>
|
||||
<div class="text-xl font-bold text-blue-700">
|
||||
{{ number_format($commissionSummary['total_received'] ?? 0) }}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 대기중 수당 --}}
|
||||
<div class="bg-yellow-50 rounded-lg p-4">
|
||||
<div class="text-sm text-yellow-600 mb-1">대기중 수당</div>
|
||||
<div class="text-xl font-bold text-yellow-700">
|
||||
{{ number_format($commissionSummary['pending_amount'] ?? 0) }}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 이번 달 계약 건수 --}}
|
||||
<div class="bg-purple-50 rounded-lg p-4">
|
||||
<div class="text-sm text-purple-600 mb-1">이번 달 계약</div>
|
||||
<div class="text-xl font-bold text-purple-700">
|
||||
{{ $commissionSummary['contracts_this_month'] ?? 0 }}건
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 최근 수당 내역 --}}
|
||||
@if (isset($recentCommissions) && $recentCommissions->count() > 0)
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3">최근 수당 내역</h4>
|
||||
<div class="space-y-2">
|
||||
@foreach ($recentCommissions as $commission)
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{{ $commission->payment_type === 'deposit' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700' }}">
|
||||
{{ $commission->payment_type_label }}
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $commission->tenant->name ?? $commission->tenant->company_name ?? '-' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ $commission->payment_date->format('Y-m-d') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold text-emerald-600">
|
||||
+{{ number_format($commission->partner_commission) }}원
|
||||
</div>
|
||||
@php
|
||||
$statusColors = [
|
||||
'pending' => 'text-yellow-600',
|
||||
'approved' => 'text-blue-600',
|
||||
'paid' => 'text-green-600',
|
||||
'cancelled' => 'text-red-600',
|
||||
];
|
||||
@endphp
|
||||
<div class="text-xs {{ $statusColors[$commission->status] ?? 'text-gray-500' }}">
|
||||
{{ $commission->status_label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p>아직 수당 내역이 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,75 +1,177 @@
|
||||
{{-- 전체 누적 실적 --}}
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold text-gray-800 mb-4">전체 누적 실적</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{{-- 영업 현황 요약 --}}
|
||||
<div x-data="{ showCommissionModal: false }" class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-800">영업 현황</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<!-- 관리 테넌트 -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-sm text-gray-500">관리 테넌트</span>
|
||||
<div class="p-2 bg-blue-50 rounded-lg">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ $tenants->total() }}개</p>
|
||||
<p class="text-xs text-gray-400 mt-1">등록된 업체 수</p>
|
||||
</div>
|
||||
|
||||
<!-- 총 가입비 -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-sm text-gray-500">총 가입비</span>
|
||||
<div class="p-2 bg-gray-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-2">₩{{ number_format($stats['total_membership_fee']) }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">전체 누적 가입비</p>
|
||||
<p class="text-3xl font-bold text-gray-900">₩{{ number_format($stats['total_membership_fee'] ?? 0) }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">전체 가입비 합계</p>
|
||||
</div>
|
||||
|
||||
<!-- 총 수당 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="text-sm text-blue-600">총 수당</span>
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
<!-- 확정 수당 (클릭 시 모달 열기) -->
|
||||
<button type="button"
|
||||
@click="showCommissionModal = true"
|
||||
class="bg-green-50 border border-green-200 rounded-xl p-5 hover:shadow-md hover:bg-green-100 transition-all text-left cursor-pointer group">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-sm text-green-700">확정 수당</span>
|
||||
<div class="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors">
|
||||
<svg class="w-5 h-5 text-green-600" 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>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-blue-700 mt-2">₩{{ number_format($stats['total_commission']) }}</p>
|
||||
<p class="text-xs text-blue-500 mt-1">지급 승인 완료 기준 ({{ $stats['commission_rate'] }}%)</p>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-green-700">₩{{ number_format($stats['total_commission'] ?? 0) }}</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<p class="text-xs text-green-600">지급 대상 금액</p>
|
||||
<span class="text-xs text-green-500 opacity-0 group-hover:opacity-100 transition-opacity">상세보기 →</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 전체 건수 -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="text-sm text-gray-500">전체 건수</span>
|
||||
<div class="p-2 bg-gray-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<!-- 승인 대기 -->
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-sm text-amber-700">승인 대기</span>
|
||||
<div class="p-2 bg-amber-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-amber-600" 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>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-2">{{ number_format($stats['total_contracts']) }}건</p>
|
||||
<p class="text-xs text-gray-400 mt-1">전체 계약 건수</p>
|
||||
<p class="text-3xl font-bold text-amber-700">{{ ($stats['pending_membership_approval'] ?? 0) + ($stats['pending_payment_approval'] ?? 0) }}건</p>
|
||||
<p class="text-xs text-amber-600 mt-1">가입/지급 승인 대기</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 가입 승인 대기 -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="text-sm text-gray-500">가입 승인 대기</span>
|
||||
<div class="p-2 bg-gray-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<!-- 역할별 수당 상세 모달 -->
|
||||
<div x-cloak
|
||||
x-show="showCommissionModal"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
@keydown.escape.window="showCommissionModal = false">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black/50" @click="showCommissionModal = false"></div>
|
||||
|
||||
<!-- 모달 컨테이너 -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div x-show="showCommissionModal"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative bg-white rounded-2xl shadow-xl max-w-2xl w-full p-6"
|
||||
@click.stop>
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-green-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800">역할별 수당 상세</h3>
|
||||
</div>
|
||||
<button type="button" @click="showCommissionModal = false"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 역할별 수당 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
@foreach($commissionByRole as $role)
|
||||
<div class="rounded-xl p-4 border
|
||||
@if($role['color'] === 'green') bg-green-50 border-green-200
|
||||
@elseif($role['color'] === 'blue') bg-blue-50 border-blue-200
|
||||
@elseif($role['color'] === 'red') bg-red-50 border-red-200
|
||||
@else bg-gray-50 border-gray-200
|
||||
@endif">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5
|
||||
@if($role['color'] === 'green') text-green-600
|
||||
@elseif($role['color'] === 'blue') text-blue-600
|
||||
@elseif($role['color'] === 'red') text-red-600
|
||||
@else text-gray-600
|
||||
@endif" 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>
|
||||
<span class="text-sm font-medium
|
||||
@if($role['color'] === 'green') text-green-800
|
||||
@elseif($role['color'] === 'blue') text-blue-800
|
||||
@elseif($role['color'] === 'red') text-red-800
|
||||
@else text-gray-800
|
||||
@endif">{{ $role['name'] }}</span>
|
||||
</div>
|
||||
@if($role['rate'] !== null)
|
||||
<span class="text-sm font-semibold
|
||||
@if($role['color'] === 'green') text-green-600
|
||||
@elseif($role['color'] === 'blue') text-blue-600
|
||||
@else text-gray-600
|
||||
@endif">{{ $role['rate'] }}%</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded">별도</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($role['amount'] !== null)
|
||||
<p class="text-2xl font-bold
|
||||
@if($role['color'] === 'green') text-green-700
|
||||
@elseif($role['color'] === 'blue') text-blue-700
|
||||
@else text-gray-700
|
||||
@endif">₩{{ number_format($role['amount']) }}</p>
|
||||
@else
|
||||
<p class="text-xl font-bold text-red-600">운영팀 산정</p>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- 총 가입비 대비 수당 -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">총 가입비 대비 수당</span>
|
||||
<p class="text-2xl font-bold text-gray-900">₩{{ number_format($totalCommissionRatio) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-2">{{ number_format($stats['pending_membership_approval']) }}건</p>
|
||||
<p class="text-xs text-gray-400 mt-1">조직 내 가입 승인 대기</p>
|
||||
</div>
|
||||
|
||||
<!-- 지급 승인 대기 -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="text-sm text-gray-500">지급 승인 대기</span>
|
||||
<div class="p-2 bg-gray-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-2">{{ number_format($stats['pending_payment_approval']) }}건</p>
|
||||
<p class="text-xs text-gray-400 mt-1">조직 내 지급 승인 대기</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
330
resources/views/sales/dashboard/partials/tenant-list.blade.php
Normal file
330
resources/views/sales/dashboard/partials/tenant-list.blade.php
Normal file
@@ -0,0 +1,330 @@
|
||||
{{-- 내 계약 현황 --}}
|
||||
<div id="tenant-list-container" class="bg-white rounded-xl shadow-sm p-6" x-data="{
|
||||
openScenarioModal(tenantId, type) {
|
||||
const url = type === 'sales'
|
||||
? `/sales/scenarios/${tenantId}/sales`
|
||||
: `/sales/scenarios/${tenantId}/manager`;
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#scenario-modal-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
},
|
||||
confirmDeleteTenant(tenantId, companyName) {
|
||||
if (confirm(`"${companyName}" 테넌트를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
fetch(`/api/admin/tenants/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
const row = document.querySelector(`.tenant-row[data-tenant-id='${tenantId}']`);
|
||||
if (row) row.remove();
|
||||
alert('테넌트가 삭제되었습니다.');
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
},
|
||||
}"
|
||||
@scenario-modal-closed.window="
|
||||
document.getElementById('scenario-modal-container').innerHTML = '';
|
||||
// 진행률 업데이트
|
||||
const detail = $event.detail;
|
||||
if (detail && detail.tenantId) {
|
||||
updateTenantProgress(detail.tenantId);
|
||||
}
|
||||
">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-800">내 계약 현황</h2>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">총 {{ $tenants->total() }}건</span>
|
||||
</div>
|
||||
|
||||
@if($tenants->isEmpty())
|
||||
<div class="text-center py-16">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-600 font-medium mb-1">아직 계약이 없습니다</p>
|
||||
<p class="text-sm text-gray-400">가망고객에서 전환된 계약이 여기에 표시됩니다</p>
|
||||
</div>
|
||||
@else
|
||||
<!-- 테이블 헤더 (숨김 처리 - 심플 레이아웃) -->
|
||||
{{-- 헤더 없는 깔끔한 리스트 스타일 --}}
|
||||
|
||||
<!-- 테넌트 목록 -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach($tenants as $tenant)
|
||||
@php
|
||||
$progress = \App\Models\Sales\SalesScenarioChecklist::getTenantProgress($tenant->id);
|
||||
$management = \App\Models\Sales\SalesTenantManagement::findOrCreateByTenant($tenant->id);
|
||||
$hqStatuses = \App\Models\Sales\SalesTenantManagement::$hqStatusLabels;
|
||||
$hqStatusOrder = \App\Models\Sales\SalesTenantManagement::$hqStatusOrder;
|
||||
$currentHqStep = $hqStatusOrder[$management->hq_status ?? 'pending'] ?? 0;
|
||||
$isHqEnabled = $progress['manager']['percentage'] >= 100;
|
||||
|
||||
// 계약 금액 정보 조회
|
||||
$contractTotals = \App\Models\Sales\SalesContractProduct::where('tenant_id', $tenant->id)
|
||||
->selectRaw('SUM(registration_fee) as total_registration_fee, SUM(subscription_fee) as total_subscription_fee, COUNT(*) as product_count')
|
||||
->first();
|
||||
$totalRegFee = $contractTotals->total_registration_fee ?? 0;
|
||||
$totalSubFee = $contractTotals->total_subscription_fee ?? 0;
|
||||
$productCount = $contractTotals->product_count ?? 0;
|
||||
@endphp
|
||||
<div class="tenant-row" data-tenant-id="{{ $tenant->id }}">
|
||||
<!-- 메인 행 -->
|
||||
<div class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 transition-colors">
|
||||
<!-- 업체명 + 관리 드롭다운 + 영업/매니저 버튼 (왼쪽 고정) -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-gray-900 truncate">{{ $tenant->company_name }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
@if($tenant->ceo_name)
|
||||
<span>대표: {{ $tenant->ceo_name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@endif
|
||||
<span>{{ $tenant->business_number ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 담당자 드롭다운 --}}
|
||||
<div class="flex-shrink-0">
|
||||
@include('sales.dashboard.partials.manager-dropdown', ['tenant' => $tenant])
|
||||
</div>
|
||||
{{-- 영업/매니저 진행 버튼 --}}
|
||||
<div class="flex-shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
x-on:click="openScenarioModal({{ $tenant->id }}, 'sales')"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
title="영업 시나리오">
|
||||
<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="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
영업
|
||||
</button>
|
||||
<button
|
||||
x-on:click="openScenarioModal({{ $tenant->id }}, 'manager')"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-600 text-white hover:bg-green-700 transition-colors"
|
||||
title="매니저 시나리오">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
매니저
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 진행 현황 (영업/매니저 | 본사 7단계) -->
|
||||
<div class="flex-1 min-w-0 grid grid-cols-2 gap-6" id="progress-{{ $tenant->id }}">
|
||||
{{-- 좌측: 영업/매니저 프로그레스 바 --}}
|
||||
<div class="space-y-1.5">
|
||||
{{-- 영업 --}}
|
||||
<div class="flex items-center gap-2" title="영업 {{ $progress['sales']['percentage'] }}%">
|
||||
<span class="text-xs font-medium text-blue-600 w-6 flex-shrink-0">영업</span>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-0">
|
||||
<div class="bg-blue-500 h-2 rounded-full transition-all" id="sales-bar-{{ $tenant->id }}" style="width: {{ $progress['sales']['percentage'] }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-10 text-right flex-shrink-0" id="sales-pct-{{ $tenant->id }}">{{ $progress['sales']['percentage'] }}%</span>
|
||||
</div>
|
||||
{{-- 매니저 --}}
|
||||
<div class="flex items-center gap-2" title="매니저 {{ $progress['manager']['percentage'] }}%">
|
||||
<span class="text-xs font-medium text-green-600 w-6 flex-shrink-0">매니</span>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-0">
|
||||
<div class="bg-green-500 h-2 rounded-full transition-all" id="manager-bar-{{ $tenant->id }}" style="width: {{ $progress['manager']['percentage'] }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-10 text-right flex-shrink-0" id="manager-pct-{{ $tenant->id }}">{{ $progress['manager']['percentage'] }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 우측: 본사 7단계 진행 상태 --}}
|
||||
<div class="flex items-center">
|
||||
@if($isHqEnabled)
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
@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-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>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="text-xs text-purple-600 font-medium mt-1 text-center">{{ $management->hq_status_label }}</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
@foreach($hqStatuses as $statusKey => $statusLabel)
|
||||
<div class="group relative flex-1">
|
||||
<div class="h-2 rounded-full 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="text-xs text-gray-400 mt-1 text-center">대기</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 계약 금액 정보 (오른쪽 세로 배치) -->
|
||||
<div class="flex-shrink-0 text-right border-l border-gray-200 pl-6">
|
||||
@if($productCount > 0)
|
||||
<div class="space-y-1">
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">가입비</p>
|
||||
<p class="text-lg font-bold text-indigo-600">₩{{ number_format($totalRegFee) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">월 구독료</p>
|
||||
<p class="text-lg font-bold text-green-600">₩{{ number_format($totalSubFee) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-1">
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">가입비</p>
|
||||
<p class="text-lg font-bold text-gray-300">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">월 구독료</p>
|
||||
<p class="text-lg font-bold text-gray-300">-</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($tenants->hasPages())
|
||||
<div class="mt-6 flex items-center justify-between border-t border-gray-200 pt-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ $tenants->firstItem() }} - {{ $tenants->lastItem() }} / 총 {{ $tenants->total() }}건
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{{-- 이전 페이지 --}}
|
||||
@if($tenants->onFirstPage())
|
||||
<span class="px-3 py-2 text-sm text-gray-300 cursor-not-allowed">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
@else
|
||||
<button type="button"
|
||||
hx-get="{{ $tenants->previousPageUrl() }}"
|
||||
hx-target="#dashboard-data"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading-indicator"
|
||||
class="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- 페이지 번호 --}}
|
||||
@foreach($tenants->getUrlRange(max(1, $tenants->currentPage() - 2), min($tenants->lastPage(), $tenants->currentPage() + 2)) as $page => $url)
|
||||
@if($page == $tenants->currentPage())
|
||||
<span class="px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg">{{ $page }}</span>
|
||||
@else
|
||||
<button type="button"
|
||||
hx-get="{{ $url }}"
|
||||
hx-target="#dashboard-data"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading-indicator"
|
||||
class="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
{{ $page }}
|
||||
</button>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- 다음 페이지 --}}
|
||||
@if($tenants->hasMorePages())
|
||||
<button type="button"
|
||||
hx-get="{{ $tenants->nextPageUrl() }}"
|
||||
hx-target="#dashboard-data"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading-indicator"
|
||||
class="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
@else
|
||||
<span class="px-3 py-2 text-sm text-gray-300 cursor-not-allowed">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- 시나리오 모달 컨테이너 --}}
|
||||
<div id="scenario-modal-container"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 테넌트 진행률 업데이트 함수
|
||||
async function updateTenantProgress(tenantId) {
|
||||
try {
|
||||
// 영업 진행률 조회
|
||||
const salesRes = await fetch(`/sales/scenarios/${tenantId}/sales/progress`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const salesData = await salesRes.json();
|
||||
|
||||
// 매니저 진행률 조회
|
||||
const managerRes = await fetch(`/sales/scenarios/${tenantId}/manager/progress`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const managerData = await managerRes.json();
|
||||
|
||||
// DOM 업데이트 - 영업
|
||||
const salesPct = document.getElementById(`sales-pct-${tenantId}`);
|
||||
const salesBar = document.getElementById(`sales-bar-${tenantId}`);
|
||||
if (salesPct && salesBar && salesData.progress) {
|
||||
salesPct.textContent = `${salesData.progress.percentage}%`;
|
||||
salesBar.style.width = `${salesData.progress.percentage}%`;
|
||||
}
|
||||
|
||||
// DOM 업데이트 - 매니저
|
||||
const managerPct = document.getElementById(`manager-pct-${tenantId}`);
|
||||
const managerBar = document.getElementById(`manager-bar-${tenantId}`);
|
||||
if (managerPct && managerBar && managerData.progress) {
|
||||
managerPct.textContent = `${managerData.progress.percentage}%`;
|
||||
managerBar.style.width = `${managerData.progress.percentage}%`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('진행률 업데이트 실패:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,88 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '영업담당자 등록')
|
||||
@section('title', '영업파트너 등록')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.doc-drop-zone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
background: #f9fafb;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.doc-drop-zone:hover, .doc-drop-zone.dragover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.doc-drop-zone.has-file {
|
||||
border-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
border-style: solid;
|
||||
}
|
||||
.doc-drop-zone-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.doc-drop-zone.dragover .doc-drop-zone-icon,
|
||||
.doc-drop-zone:hover .doc-drop-zone-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.doc-drop-zone.has-file .doc-drop-zone-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
.doc-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.doc-preview img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.doc-preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.doc-preview-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-preview-size {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.doc-preview-remove {
|
||||
padding: 4px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.doc-preview-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-3xl mx-auto">
|
||||
@@ -12,8 +94,21 @@
|
||||
</svg>
|
||||
목록으로
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">영업담당자 등록</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">등록 후 본사 승인이 필요합니다.</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">영업파트너 등록</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">등록 후 본사 승인이 필요합니다.</p>
|
||||
</div>
|
||||
@if(app()->environment('local', 'development'))
|
||||
<button type="button" onclick="fillRandomData()"
|
||||
class="p-2 bg-yellow-400 hover:bg-yellow-500 text-yellow-900 rounded-lg transition shadow-sm"
|
||||
title="테스트 데이터 자동 입력">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 폼 -->
|
||||
@@ -107,13 +202,13 @@ class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<!-- 첨부 서류 (멀티파일) -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">첨부 서류</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">승인에 필요한 서류를 첨부해주세요. (신분증, 사업자등록증, 계약서 등)</p>
|
||||
<p class="text-sm text-gray-500 mb-4">승인에 필요한 서류를 첨부해주세요. (등본사본, 통장사본)</p>
|
||||
|
||||
<div id="document-list" class="space-y-4">
|
||||
<!-- 초기 문서 입력 필드 -->
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4" data-index="0">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">문서 유형</label>
|
||||
<select name="documents[0][document_type]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
@@ -122,13 +217,17 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">파일</label>
|
||||
<input type="file" name="documents[0][file]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
accept="image/*,.pdf,.doc,.docx">
|
||||
<div class="doc-drop-zone" data-index="0">
|
||||
<input type="file" name="documents[0][file]" class="hidden" accept="image/*,.pdf,.doc,.docx">
|
||||
<svg class="doc-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-xs text-gray-500 mt-1">클릭 또는 드래그하여 업로드</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<input type="text" name="documents[0][description]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
@@ -166,18 +265,143 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
<script>
|
||||
let documentIndex = 1;
|
||||
|
||||
document.getElementById('add-document-btn').addEventListener('click', function() {
|
||||
const container = document.getElementById('document-list');
|
||||
const template = `
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4 relative">
|
||||
<button type="button" onclick="this.parentElement.remove()"
|
||||
class="absolute top-2 right-2 text-gray-400 hover:text-red-500">
|
||||
// 드래그 앤 드롭 초기화
|
||||
function initDropZone(dropZone) {
|
||||
const fileInput = dropZone.querySelector('input[type="file"]');
|
||||
|
||||
dropZone.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.doc-preview-remove')) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleFile(dropZone, e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length) {
|
||||
handleFile(dropZone, e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleFile(dropZone, file) {
|
||||
const fileInput = dropZone.querySelector('input[type="file"]');
|
||||
|
||||
// DataTransfer로 파일 설정
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
fileInput.files = dt.files;
|
||||
|
||||
// UI 업데이트
|
||||
dropZone.classList.add('has-file');
|
||||
|
||||
// 기존 미리보기 제거
|
||||
const existingPreview = dropZone.querySelector('.doc-preview');
|
||||
if (existingPreview) existingPreview.remove();
|
||||
|
||||
// 아이콘과 텍스트 숨기기
|
||||
const icon = dropZone.querySelector('.doc-drop-zone-icon');
|
||||
const text = dropZone.querySelector('p');
|
||||
if (icon) icon.style.display = 'none';
|
||||
if (text) text.style.display = 'none';
|
||||
|
||||
// 미리보기 생성
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'doc-preview';
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const fileSize = formatFileSize(file.size);
|
||||
|
||||
if (isImage) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
preview.innerHTML = `
|
||||
<img src="${e.target.result}" alt="Preview">
|
||||
<div class="doc-preview-info">
|
||||
<div class="doc-preview-name">${file.name}</div>
|
||||
<div class="doc-preview-size">${fileSize}</div>
|
||||
</div>
|
||||
<button type="button" class="doc-preview-remove" onclick="removeFile(this)">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
const ext = file.name.split('.').pop().toUpperCase();
|
||||
preview.innerHTML = `
|
||||
<div class="w-10 h-10 bg-gray-100 rounded flex items-center justify-center text-xs font-medium text-gray-500">${ext}</div>
|
||||
<div class="doc-preview-info">
|
||||
<div class="doc-preview-name">${file.name}</div>
|
||||
<div class="doc-preview-size">${fileSize}</div>
|
||||
</div>
|
||||
<button type="button" class="doc-preview-remove" onclick="removeFile(this)">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
`;
|
||||
}
|
||||
|
||||
dropZone.appendChild(preview);
|
||||
}
|
||||
|
||||
function removeFile(btn) {
|
||||
const dropZone = btn.closest('.doc-drop-zone');
|
||||
const fileInput = dropZone.querySelector('input[type="file"]');
|
||||
const preview = dropZone.querySelector('.doc-preview');
|
||||
const icon = dropZone.querySelector('.doc-drop-zone-icon');
|
||||
const text = dropZone.querySelector('p');
|
||||
|
||||
// 파일 input 초기화
|
||||
fileInput.value = '';
|
||||
|
||||
// 미리보기 제거
|
||||
if (preview) preview.remove();
|
||||
|
||||
// 아이콘과 텍스트 다시 표시
|
||||
if (icon) icon.style.display = '';
|
||||
if (text) text.style.display = '';
|
||||
|
||||
dropZone.classList.remove('has-file');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
|
||||
// 문서 추가 버튼
|
||||
document.getElementById('add-document-btn').addEventListener('click', function() {
|
||||
const container = document.getElementById('document-list');
|
||||
const template = `
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4 relative" data-index="${documentIndex}">
|
||||
<button type="button" onclick="this.parentElement.remove()"
|
||||
class="absolute top-2 right-2 text-gray-400 hover:text-red-500 z-10">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">문서 유형</label>
|
||||
<select name="documents[${documentIndex}][document_type]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
@@ -186,13 +410,17 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">파일</label>
|
||||
<input type="file" name="documents[${documentIndex}][file]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
accept="image/*,.pdf,.doc,.docx">
|
||||
<div class="doc-drop-zone" data-index="${documentIndex}">
|
||||
<input type="file" name="documents[${documentIndex}][file]" class="hidden" accept="image/*,.pdf,.doc,.docx">
|
||||
<svg class="doc-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-xs text-gray-500 mt-1">클릭 또는 드래그하여 업로드</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<input type="text" name="documents[${documentIndex}][description]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
@@ -202,7 +430,64 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', template);
|
||||
|
||||
// 새로 추가된 드롭존 초기화
|
||||
const newItem = container.lastElementChild;
|
||||
const newDropZone = newItem.querySelector('.doc-drop-zone');
|
||||
initDropZone(newDropZone);
|
||||
|
||||
documentIndex++;
|
||||
});
|
||||
|
||||
// 초기 드롭존 초기화
|
||||
document.querySelectorAll('.doc-drop-zone').forEach(initDropZone);
|
||||
|
||||
// 테스트 데이터 자동 입력 (번개 버튼)
|
||||
function fillRandomData() {
|
||||
// 한글 성씨와 이름 목록
|
||||
const lastNames = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
|
||||
const firstNames = ['민준', '서연', '예준', '서윤', '도윤', '지우', '시우', '하윤', '주원', '지호', '수빈', '유진', '지민', '현우', '승현'];
|
||||
|
||||
// 랜덤 문자열 생성
|
||||
const randomStr = () => Math.random().toString(36).substring(2, 8);
|
||||
const randomNum = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
|
||||
// 랜덤 이름 (3자)
|
||||
const name = lastNames[randomNum(0, lastNames.length - 1)] +
|
||||
firstNames[randomNum(0, firstNames.length - 1)].substring(0, 2);
|
||||
|
||||
// 랜덤 ID와 이메일
|
||||
const userId = 'test_' + randomStr();
|
||||
const email = userId + '@example.com';
|
||||
|
||||
// 랜덤 전화번호
|
||||
const phone = '010-' + randomNum(1000, 9999) + '-' + randomNum(1000, 9999);
|
||||
|
||||
// 고정 비밀번호 (테스트용)
|
||||
const password = '12341234';
|
||||
|
||||
// 폼 필드 채우기
|
||||
document.querySelector('input[name="user_id"]').value = userId;
|
||||
document.querySelector('input[name="name"]').value = name;
|
||||
document.querySelector('input[name="email"]').value = email;
|
||||
document.querySelector('input[name="phone"]').value = phone;
|
||||
document.querySelector('input[name="password"]').value = password;
|
||||
document.querySelector('input[name="password_confirmation"]').value = password;
|
||||
|
||||
// 역할 체크박스 모두 체크
|
||||
document.querySelectorAll('input[name="role_ids[]"]').forEach(cb => {
|
||||
cb.checked = true;
|
||||
});
|
||||
|
||||
// 콘솔에 비밀번호 출력 (테스트용)
|
||||
console.log('=== 테스트 계정 정보 ===');
|
||||
console.log('ID:', userId);
|
||||
console.log('이메일:', email);
|
||||
console.log('비밀번호:', password);
|
||||
console.log('========================');
|
||||
|
||||
// 알림
|
||||
alert('테스트 데이터가 입력되었습니다.\n\n비밀번호: 12341234');
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -1,6 +1,88 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '영업담당자 수정')
|
||||
@section('title', '영업파트너 수정')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.doc-drop-zone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
background: #f9fafb;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.doc-drop-zone:hover, .doc-drop-zone.dragover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.doc-drop-zone.has-file {
|
||||
border-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
border-style: solid;
|
||||
}
|
||||
.doc-drop-zone-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.doc-drop-zone.dragover .doc-drop-zone-icon,
|
||||
.doc-drop-zone:hover .doc-drop-zone-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.doc-drop-zone.has-file .doc-drop-zone-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
.doc-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.doc-preview img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.doc-preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.doc-preview-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-preview-size {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.doc-preview-remove {
|
||||
padding: 4px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.doc-preview-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-3xl mx-auto">
|
||||
@@ -12,7 +94,7 @@
|
||||
</svg>
|
||||
목록으로
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">영업담당자 수정</h1>
|
||||
<h1 class="text-2xl font-bold text-gray-800">영업파트너 수정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $partner->name }} ({{ $partner->email }})</p>
|
||||
</div>
|
||||
|
||||
@@ -143,9 +225,9 @@ class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">새 서류 추가</h2>
|
||||
|
||||
<div id="document-list" class="space-y-4">
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4" data-index="0">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">문서 유형</label>
|
||||
<select name="documents[0][document_type]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
@@ -154,13 +236,17 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">파일</label>
|
||||
<input type="file" name="documents[0][file]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
accept="image/*,.pdf,.doc,.docx">
|
||||
<div class="doc-drop-zone" data-index="0">
|
||||
<input type="file" name="documents[0][file]" class="hidden" accept="image/*,.pdf,.doc,.docx">
|
||||
<svg class="doc-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-xs text-gray-500 mt-1">클릭 또는 드래그하여 업로드</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<input type="text" name="documents[0][description]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
@@ -198,18 +284,143 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
<script>
|
||||
let documentIndex = 1;
|
||||
|
||||
document.getElementById('add-document-btn').addEventListener('click', function() {
|
||||
const container = document.getElementById('document-list');
|
||||
const template = `
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4 relative">
|
||||
<button type="button" onclick="this.parentElement.remove()"
|
||||
class="absolute top-2 right-2 text-gray-400 hover:text-red-500">
|
||||
// 드래그 앤 드롭 초기화
|
||||
function initDropZone(dropZone) {
|
||||
const fileInput = dropZone.querySelector('input[type="file"]');
|
||||
|
||||
dropZone.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.doc-preview-remove')) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleFile(dropZone, e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length) {
|
||||
handleFile(dropZone, e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleFile(dropZone, file) {
|
||||
const fileInput = dropZone.querySelector('input[type="file"]');
|
||||
|
||||
// DataTransfer로 파일 설정
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
fileInput.files = dt.files;
|
||||
|
||||
// UI 업데이트
|
||||
dropZone.classList.add('has-file');
|
||||
|
||||
// 기존 미리보기 제거
|
||||
const existingPreview = dropZone.querySelector('.doc-preview');
|
||||
if (existingPreview) existingPreview.remove();
|
||||
|
||||
// 아이콘과 텍스트 숨기기
|
||||
const icon = dropZone.querySelector('.doc-drop-zone-icon');
|
||||
const text = dropZone.querySelector('p');
|
||||
if (icon) icon.style.display = 'none';
|
||||
if (text) text.style.display = 'none';
|
||||
|
||||
// 미리보기 생성
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'doc-preview';
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const fileSize = formatFileSize(file.size);
|
||||
|
||||
if (isImage) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
preview.innerHTML = `
|
||||
<img src="${e.target.result}" alt="Preview">
|
||||
<div class="doc-preview-info">
|
||||
<div class="doc-preview-name">${file.name}</div>
|
||||
<div class="doc-preview-size">${fileSize}</div>
|
||||
</div>
|
||||
<button type="button" class="doc-preview-remove" onclick="removeFile(this)">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
const ext = file.name.split('.').pop().toUpperCase();
|
||||
preview.innerHTML = `
|
||||
<div class="w-10 h-10 bg-gray-100 rounded flex items-center justify-center text-xs font-medium text-gray-500">${ext}</div>
|
||||
<div class="doc-preview-info">
|
||||
<div class="doc-preview-name">${file.name}</div>
|
||||
<div class="doc-preview-size">${fileSize}</div>
|
||||
</div>
|
||||
<button type="button" class="doc-preview-remove" onclick="removeFile(this)">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
`;
|
||||
}
|
||||
|
||||
dropZone.appendChild(preview);
|
||||
}
|
||||
|
||||
function removeFile(btn) {
|
||||
const dropZone = btn.closest('.doc-drop-zone');
|
||||
const fileInput = dropZone.querySelector('input[type="file"]');
|
||||
const preview = dropZone.querySelector('.doc-preview');
|
||||
const icon = dropZone.querySelector('.doc-drop-zone-icon');
|
||||
const text = dropZone.querySelector('p');
|
||||
|
||||
// 파일 input 초기화
|
||||
fileInput.value = '';
|
||||
|
||||
// 미리보기 제거
|
||||
if (preview) preview.remove();
|
||||
|
||||
// 아이콘과 텍스트 다시 표시
|
||||
if (icon) icon.style.display = '';
|
||||
if (text) text.style.display = '';
|
||||
|
||||
dropZone.classList.remove('has-file');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
|
||||
// 문서 추가 버튼
|
||||
document.getElementById('add-document-btn').addEventListener('click', function() {
|
||||
const container = document.getElementById('document-list');
|
||||
const template = `
|
||||
<div class="document-item border border-gray-200 rounded-lg p-4 relative" data-index="${documentIndex}">
|
||||
<button type="button" onclick="this.parentElement.remove()"
|
||||
class="absolute top-2 right-2 text-gray-400 hover:text-red-500 z-10">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">문서 유형</label>
|
||||
<select name="documents[${documentIndex}][document_type]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
@@ -218,13 +429,17 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">파일</label>
|
||||
<input type="file" name="documents[${documentIndex}][file]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
accept="image/*,.pdf,.doc,.docx">
|
||||
<div class="doc-drop-zone" data-index="${documentIndex}">
|
||||
<input type="file" name="documents[${documentIndex}][file]" class="hidden" accept="image/*,.pdf,.doc,.docx">
|
||||
<svg class="doc-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-xs text-gray-500 mt-1">클릭 또는 드래그하여 업로드</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<input type="text" name="documents[${documentIndex}][description]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
@@ -234,7 +449,16 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', template);
|
||||
|
||||
// 새로 추가된 드롭존 초기화
|
||||
const newItem = container.lastElementChild;
|
||||
const newDropZone = newItem.querySelector('.doc-drop-zone');
|
||||
initDropZone(newDropZone);
|
||||
|
||||
documentIndex++;
|
||||
});
|
||||
|
||||
// 초기 드롭존 초기화
|
||||
document.querySelectorAll('.doc-drop-zone').forEach(initDropZone);
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -151,8 +151,8 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
{{ $partner->created_at->format('Y-m-d') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('sales.managers.show', $partner->id) }}" class="text-blue-600 hover:text-blue-900 mr-3">상세</a>
|
||||
<a href="{{ route('sales.managers.edit', $partner->id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">수정</a>
|
||||
<button type="button" onclick="openShowModal({{ $partner->id }})" class="text-blue-600 hover:text-blue-900 mr-3">상세</button>
|
||||
<button type="button" onclick="openEditModal({{ $partner->id }})" class="text-indigo-600 hover:text-indigo-900 mr-3">수정</button>
|
||||
@if($partner->isPendingApproval())
|
||||
<form action="{{ route('sales.managers.approve', $partner->id) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('승인하시겠습니까?')">
|
||||
@@ -187,4 +187,133 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 파트너 모달 -->
|
||||
<div id="partnerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
|
||||
<!-- 모달 컨텐츠 wrapper -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="partnerModalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-3xl">
|
||||
<!-- 로딩 표시 -->
|
||||
<div id="modalLoading" class="p-12 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-gray-500">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 전역 스코프에 함수 등록 (AJAX로 로드된 HTML에서 접근 가능하도록)
|
||||
window.openShowModal = function(id) {
|
||||
const modal = document.getElementById('partnerModal');
|
||||
const content = document.getElementById('partnerModalContent');
|
||||
|
||||
// 모달 표시
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden'; // 배경 스크롤 방지
|
||||
|
||||
// 로딩 표시
|
||||
content.innerHTML = `
|
||||
<div id="modalLoading" class="p-12 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-gray-500">로딩 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// AJAX로 상세 정보 가져오기
|
||||
fetch(`/sales/managers/${id}/modal-show`, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
}
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
content.innerHTML = `
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-red-500">오류가 발생했습니다.</p>
|
||||
<button onclick="closePartnerModal()" class="mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg">닫기</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 모달 열기
|
||||
window.openEditModal = function(id) {
|
||||
const modal = document.getElementById('partnerModal');
|
||||
const content = document.getElementById('partnerModalContent');
|
||||
|
||||
// 모달 표시
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden'; // 배경 스크롤 방지
|
||||
|
||||
// 로딩 표시
|
||||
content.innerHTML = `
|
||||
<div id="modalLoading" class="p-12 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-gray-500">로딩 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// AJAX로 수정 폼 가져오기
|
||||
fetch(`/sales/managers/${id}/modal-edit`, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html'
|
||||
}
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
content.innerHTML = `
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-red-500">오류가 발생했습니다.</p>
|
||||
<button onclick="closePartnerModal()" class="mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg">닫기</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
window.closePartnerModal = function() {
|
||||
const modal = document.getElementById('partnerModal');
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = ''; // 배경 스크롤 복원
|
||||
}
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closePartnerModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 이벤트 델리게이션: data-close-modal 속성을 가진 요소 클릭 시 모달 닫기
|
||||
document.addEventListener('click', function(e) {
|
||||
const closeBtn = e.target.closest('[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.closePartnerModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
193
resources/views/sales/managers/partials/edit-modal.blade.php
Normal file
193
resources/views/sales/managers/partials/edit-modal.blade.php
Normal file
@@ -0,0 +1,193 @@
|
||||
{{-- 영업파트너 수정 모달 내용 --}}
|
||||
<div class="p-6 max-h-[80vh] overflow-y-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">영업파트너 수정</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $partner->name }} ({{ $partner->email }})</p>
|
||||
</div>
|
||||
<button type="button" data-close-modal class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
|
||||
<!-- 폼 -->
|
||||
<form action="{{ route('sales.managers.update', $partner->id) }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">기본 정보</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">로그인 ID</label>
|
||||
<input type="text" value="{{ $partner->user_id ?? $partner->email }}" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">이름 <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="name" value="{{ $partner->name }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">이메일 <span class="text-red-500">*</span></label>
|
||||
<input type="email" name="email" value="{{ $partner->email }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">전화번호</label>
|
||||
<input type="text" name="phone" value="{{ $partner->phone }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<input type="password" name="password" placeholder="변경 시에만 입력"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">비밀번호 확인</label>
|
||||
<input type="password" name="password_confirmation" placeholder="변경 시에만 입력"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 역할 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">역할 <span class="text-red-500">*</span></h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
@foreach($roles as $role)
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="role_ids[]" value="{{ $role->id }}"
|
||||
{{ in_array($role->id, $currentRoleIds) ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">
|
||||
{{ $role->description ?? $role->name }}
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추천인 정보 -->
|
||||
@if($partner->parent)
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2">추천인(유치자)</h3>
|
||||
<div class="px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm text-gray-700">
|
||||
{{ $partner->parent->name }} ({{ $partner->parent->email }})
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">추천인은 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 기존 첨부 서류 -->
|
||||
@if($partner->salesDocuments->isNotEmpty())
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">기존 첨부 서류</h3>
|
||||
<div class="space-y-2">
|
||||
@foreach($partner->salesDocuments as $document)
|
||||
<div class="flex items-center justify-between p-2 bg-white rounded border">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ $document->document_type_label }}</span>
|
||||
<span class="text-sm text-gray-700">{{ $document->original_name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ $document->formatted_size }}</span>
|
||||
</div>
|
||||
<button type="button" onclick="deleteDocument({{ $partner->id }}, {{ $document->id }})"
|
||||
class="text-red-500 hover:text-red-700">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 새 첨부 서류 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">새 서류 추가</h3>
|
||||
<div id="modal-document-list" class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<select name="documents[0][document_type]"
|
||||
class="w-32 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($documentTypes as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="file" name="documents[0][file]" accept="image/*,.pdf,.doc,.docx"
|
||||
class="flex-1 text-sm text-gray-500 file:mr-2 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
<input type="text" name="documents[0][description]" placeholder="설명(선택)"
|
||||
class="w-32 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="addDocumentRow()"
|
||||
class="mt-3 text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
서류 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 버튼 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" data-close-modal
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition text-sm">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-sm">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let modalDocIndex = 1;
|
||||
|
||||
function addDocumentRow() {
|
||||
const container = document.getElementById('modal-document-list');
|
||||
const html = `
|
||||
<div class="flex items-center gap-3">
|
||||
<select name="documents[${modalDocIndex}][document_type]"
|
||||
class="w-32 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($documentTypes as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="file" name="documents[${modalDocIndex}][file]" accept="image/*,.pdf,.doc,.docx"
|
||||
class="flex-1 text-sm text-gray-500 file:mr-2 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
<input type="text" name="documents[${modalDocIndex}][description]" placeholder="설명(선택)"
|
||||
class="w-32 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<button type="button" onclick="this.parentElement.remove()" class="text-red-500 hover:text-red-700">
|
||||
<svg class="w-4 h-4" 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>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
modalDocIndex++;
|
||||
}
|
||||
|
||||
function deleteDocument(partnerId, documentId) {
|
||||
if (!confirm('이 서류를 삭제하시겠습니까?')) return;
|
||||
|
||||
fetch(`/sales/managers/${partnerId}/documents/${documentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
openEditModal(partnerId); // 모달 새로고침
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
290
resources/views/sales/managers/partials/show-modal.blade.php
Normal file
290
resources/views/sales/managers/partials/show-modal.blade.php
Normal file
@@ -0,0 +1,290 @@
|
||||
{{-- 영업파트너 상세 모달 내용 --}}
|
||||
<div class="p-6 max-h-[80vh] overflow-y-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">{{ $partner->name }}</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">레벨 {{ $level }} 영업파트너</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
@foreach($partner->userRoles as $userRole)
|
||||
@php
|
||||
$roleColor = match($userRole->role->name ?? '') {
|
||||
'sales' => 'bg-blue-100 text-blue-800',
|
||||
'manager' => 'bg-purple-100 text-purple-800',
|
||||
'recruiter' => 'bg-green-100 text-green-800',
|
||||
default => 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
$roleLabel = match($userRole->role->name ?? '') {
|
||||
'sales' => '영업',
|
||||
'manager' => '매니저',
|
||||
'recruiter' => '유치담당',
|
||||
default => $userRole->role->name ?? '-',
|
||||
};
|
||||
@endphp
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full {{ $roleColor }}">
|
||||
{{ $roleLabel }}
|
||||
</span>
|
||||
@endforeach
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full {{ $partner->approval_status_color }}">
|
||||
{{ $partner->approval_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" data-close-modal class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
|
||||
<!-- 승인 대기 알림 -->
|
||||
@if($partner->isPendingApproval())
|
||||
<div class="mb-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-yellow-800 text-sm">승인 대기 중</h3>
|
||||
<p class="text-xs text-yellow-700 mt-1">첨부된 서류를 확인 후 승인 또는 반려해주세요.</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<form action="{{ route('sales.managers.approve', $partner->id) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<button type="submit" onclick="return confirm('승인하시겠습니까?')"
|
||||
class="px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition text-xs">
|
||||
승인
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="showRejectForm()"
|
||||
class="px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition text-xs">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
<!-- 반려 폼 (숨김) -->
|
||||
<form id="rejectForm" action="{{ route('sales.managers.reject', $partner->id) }}" method="POST" class="hidden mt-3">
|
||||
@csrf
|
||||
<textarea name="rejection_reason" rows="2" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
placeholder="반려 사유를 입력해주세요."></textarea>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="submit" class="px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition text-xs">
|
||||
반려 확인
|
||||
</button>
|
||||
<button type="button" onclick="hideRejectForm()" class="px-3 py-1.5 border border-gray-300 rounded-lg hover:bg-gray-50 transition text-xs">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 반려된 경우 사유 표시 -->
|
||||
@if($partner->isRejected() && $partner->rejection_reason)
|
||||
<div class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-semibold text-red-800 text-sm">반려됨</h3>
|
||||
<p class="text-sm text-red-700 mt-1">{{ $partner->rejection_reason }}</p>
|
||||
@if($partner->approver)
|
||||
<p class="text-xs text-red-600 mt-1">처리자: {{ $partner->approver->name }} ({{ $partner->approved_at->format('Y-m-d H:i') }})</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">기본 정보</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">로그인 ID</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $partner->user_id ?? $partner->email }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">이름</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $partner->name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">이메일</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $partner->email }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">전화번호</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $partner->phone ?? '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">추천인(유치자)</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
@if($partner->parent)
|
||||
{{ $partner->parent->name }}
|
||||
@else
|
||||
<span class="text-gray-400">최상위</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">등록일</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $partner->created_at->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@if($partner->isApproved() && $partner->approved_at)
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">승인일</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $partner->approved_at->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">활동 통계</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-blue-100 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-blue-800">{{ $children->count() }}</div>
|
||||
<div class="text-xs text-blue-600">하위 파트너</div>
|
||||
</div>
|
||||
<div class="bg-green-100 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-green-800">{{ $partner->salesDocuments->count() }}</div>
|
||||
<div class="text-xs text-green-600">첨부 서류</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 역할 관리 (승인된 파트너만) -->
|
||||
@if($partner->isApproved())
|
||||
<div class="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">역할 관리</h3>
|
||||
@php
|
||||
$currentRoles = $partner->userRoles->pluck('role.name')->toArray();
|
||||
$roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
|
||||
$roleColors = [
|
||||
'sales' => 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'manager' => 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'recruiter' => 'bg-green-100 text-green-800 border-green-200',
|
||||
];
|
||||
@endphp
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
@forelse($currentRoles as $roleName)
|
||||
@if(isset($roleLabels[$roleName]))
|
||||
<div class="flex items-center gap-1 px-2 py-1 rounded-full border {{ $roleColors[$roleName] ?? 'bg-gray-100' }}">
|
||||
<span class="text-xs font-medium">{{ $roleLabels[$roleName] }}</span>
|
||||
<form action="{{ route('sales.managers.remove-role', $partner->id) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="role_name" value="{{ $roleName }}">
|
||||
<button type="submit" onclick="return confirm('이 역할을 제거하시겠습니까?')"
|
||||
class="ml-1 text-gray-400 hover:text-red-500">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
@empty
|
||||
<span class="text-gray-400 text-xs">역할이 없습니다</span>
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach(['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'] as $roleName => $label)
|
||||
@if(!in_array($roleName, $currentRoles))
|
||||
<form action="{{ route('sales.managers.assign-role', $partner->id) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="role_name" value="{{ $roleName }}">
|
||||
<button type="submit"
|
||||
class="px-2 py-1 text-xs border border-gray-300 rounded-full hover:bg-gray-100 transition">
|
||||
+ {{ $label }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 첨부 서류 -->
|
||||
@if($partner->salesDocuments->isNotEmpty())
|
||||
<div class="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">첨부 서류</h3>
|
||||
<div class="space-y-2">
|
||||
@foreach($partner->salesDocuments as $document)
|
||||
<div class="flex items-center justify-between p-2 bg-white rounded border">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ $document->document_type_label }}</span>
|
||||
<span class="text-sm text-gray-700">{{ $document->original_name }}</span>
|
||||
</div>
|
||||
<a href="{{ route('sales.managers.documents.download', [$partner->id, $document->id]) }}"
|
||||
class="text-xs text-blue-600 hover:underline">다운로드</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 하위 파트너 -->
|
||||
@if($children->isNotEmpty())
|
||||
<div class="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">하위 파트너 ({{ $children->count() }}명)</h3>
|
||||
<div class="space-y-2">
|
||||
@foreach($children->take(5) as $child)
|
||||
<div class="flex items-center justify-between p-2 bg-white rounded border">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $child->name }}</span>
|
||||
@foreach($child->userRoles as $userRole)
|
||||
@php
|
||||
$roleColor = match($userRole->role->name ?? '') {
|
||||
'sales' => 'bg-blue-100 text-blue-800',
|
||||
'manager' => 'bg-purple-100 text-purple-800',
|
||||
'recruiter' => 'bg-green-100 text-green-800',
|
||||
default => 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
$roleLabel = match($userRole->role->name ?? '') {
|
||||
'sales' => '영업',
|
||||
'manager' => '매니저',
|
||||
'recruiter' => '유치담당',
|
||||
default => $userRole->role->name ?? '-',
|
||||
};
|
||||
@endphp
|
||||
<span class="px-1.5 py-0.5 text-xs font-medium rounded {{ $roleColor }}">{{ $roleLabel }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full {{ $child->approval_status_color }}">
|
||||
{{ $child->approval_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@if($children->count() > 5)
|
||||
<p class="text-xs text-gray-500 text-center mt-2">외 {{ $children->count() - 5 }}명...</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 푸터 버튼 -->
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" data-close-modal
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition text-sm">
|
||||
닫기
|
||||
</button>
|
||||
<button type="button" onclick="openEditModal({{ $partner->id }})"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition text-sm">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showRejectForm() {
|
||||
document.getElementById('rejectForm').classList.remove('hidden');
|
||||
}
|
||||
function hideRejectForm() {
|
||||
document.getElementById('rejectForm').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
313
resources/views/sales/modals/consultation-log.blade.php
Normal file
313
resources/views/sales/modals/consultation-log.blade.php
Normal file
@@ -0,0 +1,313 @@
|
||||
{{-- 상담 기록 컴포넌트 --}}
|
||||
<div x-data="consultationLog()" class="space-y-4">
|
||||
{{-- 상담 기록 입력 --}}
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">상담 기록 추가</h4>
|
||||
<div class="space-y-3">
|
||||
<textarea
|
||||
x-model="newContent"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
placeholder="상담 내용을 입력하세요..."
|
||||
></textarea>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="saveConsultation()"
|
||||
:disabled="!newContent.trim() || saving"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span x-text="saving ? '저장 중...' : '저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 상담 기록 목록 --}}
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ $consultations->count() }}건)</h4>
|
||||
|
||||
@if($consultations->isEmpty())
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto mb-2 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p class="text-sm">아직 상담 기록이 없습니다.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3 max-h-80 overflow-y-auto">
|
||||
@foreach($consultations as $consultation)
|
||||
<div class="group relative bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
|
||||
data-consultation-id="{{ $consultation->id }}">
|
||||
{{-- 삭제 버튼 --}}
|
||||
<button
|
||||
@click="deleteConsultation({{ $consultation->id }})"
|
||||
class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 콘텐츠 --}}
|
||||
<div class="flex items-start gap-3">
|
||||
{{-- 타입 아이콘 --}}
|
||||
<div class="flex-shrink-0 p-2 rounded-lg
|
||||
@if($consultation->consultation_type === 'text') bg-blue-100 text-blue-600
|
||||
@elseif($consultation->consultation_type === 'audio') bg-purple-100 text-purple-600
|
||||
@else bg-green-100 text-green-600 @endif">
|
||||
@if($consultation->consultation_type === 'text')
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
@elseif($consultation->consultation_type === 'audio')
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
@if($consultation->consultation_type === 'text')
|
||||
<div class="max-h-32 overflow-y-auto">
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{{ $consultation->content }}</p>
|
||||
</div>
|
||||
@elseif($consultation->consultation_type === 'audio')
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900">음성 녹음</span>
|
||||
@if($consultation->duration)
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ $consultation->formatted_duration }}
|
||||
</span>
|
||||
@endif
|
||||
@if($consultation->gcs_uri)
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 text-green-700 text-xs rounded">
|
||||
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
GCS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 오디오 플레이어 --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<audio
|
||||
id="audio-player-{{ $consultation->id }}"
|
||||
class="hidden"
|
||||
preload="none"
|
||||
></audio>
|
||||
<button
|
||||
type="button"
|
||||
onclick="toggleAudioPlay({{ $consultation->id }}, '{{ route('sales.consultations.download-audio', $consultation->id) }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
id="play-btn-{{ $consultation->id }}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>재생</span>
|
||||
</button>
|
||||
<a
|
||||
href="{{ route('sales.consultations.download-audio', $consultation->id) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>다운로드</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if($consultation->transcript)
|
||||
<div class="max-h-32 overflow-y-auto bg-gray-50 rounded p-2">
|
||||
<p class="text-sm text-gray-600 italic leading-relaxed">"{{ $consultation->transcript }}"</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
{{-- 첨부파일 --}}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $consultation->file_name }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ $consultation->formatted_file_size }}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="{{ route('sales.consultations.download-file', $consultation->id) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>다운로드</span>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 메타 정보 --}}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{{ $consultation->creator?->name ?? '알 수 없음' }}</span>
|
||||
<span>|</span>
|
||||
<span>{{ $consultation->created_at->format('Y-m-d H:i') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 오디오 재생/정지 토글
|
||||
function toggleAudioPlay(consultationId, audioUrl) {
|
||||
const audio = document.getElementById('audio-player-' + consultationId);
|
||||
const btn = document.getElementById('play-btn-' + consultationId);
|
||||
|
||||
if (!audio || !btn) return;
|
||||
|
||||
// 오디오 소스가 없으면 설정
|
||||
if (!audio.src) {
|
||||
audio.src = audioUrl;
|
||||
}
|
||||
|
||||
if (audio.paused) {
|
||||
// 다른 재생 중인 오디오 중지
|
||||
document.querySelectorAll('audio').forEach(a => {
|
||||
if (a !== audio && !a.paused) {
|
||||
a.pause();
|
||||
const otherId = a.id.replace('audio-player-', '');
|
||||
const otherBtn = document.getElementById('play-btn-' + otherId);
|
||||
if (otherBtn) {
|
||||
otherBtn.innerHTML = `
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>재생</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
audio.play();
|
||||
btn.innerHTML = `
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>정지</span>
|
||||
`;
|
||||
|
||||
// 재생 종료 시 버튼 복구
|
||||
audio.onended = function() {
|
||||
btn.innerHTML = `
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>재생</span>
|
||||
`;
|
||||
};
|
||||
} else {
|
||||
audio.pause();
|
||||
btn.innerHTML = `
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>재생</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function consultationLog() {
|
||||
return {
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
newContent: '',
|
||||
saving: false,
|
||||
|
||||
async saveConsultation() {
|
||||
if (!this.newContent.trim() || this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const response = await fetch('{{ route('sales.consultations.store') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: this.tenantId,
|
||||
scenario_type: this.scenarioType,
|
||||
step_id: this.stepId,
|
||||
content: this.newContent,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.newContent = '';
|
||||
// 목록 새로고침
|
||||
htmx.ajax('GET',
|
||||
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}`,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
} else {
|
||||
alert('저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상담 기록 저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteConsultation(consultationId) {
|
||||
if (!confirm('이 상담 기록을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/sales/consultations/${consultationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// DOM에서 제거
|
||||
const element = document.querySelector(`[data-consultation-id="${consultationId}"]`);
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상담 기록 삭제 실패:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
256
resources/views/sales/modals/file-uploader.blade.php
Normal file
256
resources/views/sales/modals/file-uploader.blade.php
Normal file
@@ -0,0 +1,256 @@
|
||||
{{-- 첨부파일 업로드 컴포넌트 (자동 업로드) --}}
|
||||
<div x-data="fileUploader()" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
|
||||
|
||||
{{-- 업로드 중 오버레이 --}}
|
||||
<div x-show="uploading"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="absolute inset-0 z-10 bg-white/95 flex flex-col items-center justify-center">
|
||||
<div class="w-48 space-y-4">
|
||||
{{-- 아이콘 --}}
|
||||
<div class="flex justify-center">
|
||||
<div class="p-4 bg-green-100 rounded-full">
|
||||
<svg class="w-8 h-8 text-green-600 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 텍스트 --}}
|
||||
<p class="text-center text-sm font-medium text-gray-700">파일 업로드 중...</p>
|
||||
{{-- 프로그레스바 --}}
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-green-600 transition-all duration-300 ease-out rounded-full"
|
||||
:style="'width: ' + totalProgress + '%'"></div>
|
||||
</div>
|
||||
<p class="text-center text-xs text-gray-500" x-text="uploadStatus"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 헤더 (접기/펼치기) --}}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
:disabled="uploading"
|
||||
class="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors disabled:opacity-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700">첨부파일</span>
|
||||
<span x-show="uploadedCount > 0" class="px-2 py-0.5 bg-green-100 text-green-600 text-xs font-medium rounded-full" x-text="uploadedCount + '개 완료'"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400" x-text="expanded ? '접기' : '펼치기'"></span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
|
||||
:class="expanded && 'rotate-180'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{{-- 콘텐츠 (접기/펼치기) --}}
|
||||
<div x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="px-4 pb-4 border-t border-gray-100">
|
||||
|
||||
{{-- Drag & Drop 영역 --}}
|
||||
<div
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop($event)"
|
||||
:class="isDragging ? 'border-green-500 bg-green-50' : 'border-gray-300 bg-gray-50'"
|
||||
class="mt-4 border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
x-ref="fileInput"
|
||||
@change="handleFileSelect($event)"
|
||||
multiple
|
||||
class="hidden"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif,.zip,.rar"
|
||||
>
|
||||
|
||||
<svg class="w-10 h-10 mx-auto mb-3" :class="isDragging ? 'text-green-500' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-1">
|
||||
파일을 여기에 드래그하거나 <span class="text-green-600 font-medium">클릭하여 선택</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
선택 즉시 자동 업로드 / 최대 20MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- 최근 업로드 파일 목록 --}}
|
||||
<div x-show="recentFiles.length > 0" class="mt-4 space-y-2">
|
||||
<h5 class="text-xs font-medium text-gray-500 uppercase tracking-wider">최근 업로드</h5>
|
||||
<template x-for="(file, index) in recentFiles" :key="index">
|
||||
<div class="flex items-center gap-3 p-3 bg-green-50 rounded-lg border border-green-100">
|
||||
{{-- 파일 아이콘 --}}
|
||||
<div class="flex-shrink-0 p-2 bg-white rounded-lg border border-green-200">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- 파일 정보 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate" x-text="file.name"></p>
|
||||
<p class="text-xs text-green-600">업로드 완료</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- 안내 문구 --}}
|
||||
<p class="mt-3 text-xs text-gray-400 text-center">
|
||||
PDF, 문서, 이미지, 압축파일 지원
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fileUploader() {
|
||||
return {
|
||||
tenantId: {{ $tenant->id }},
|
||||
expanded: false,
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
|
||||
isDragging: false,
|
||||
uploading: false,
|
||||
totalProgress: 0,
|
||||
uploadStatus: '',
|
||||
uploadedCount: 0,
|
||||
recentFiles: [],
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
this.isDragging = false;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
this.uploadFiles(files);
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.uploadFiles(files);
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
async uploadFiles(files) {
|
||||
if (this.uploading || files.length === 0) return;
|
||||
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
const validFiles = files.filter(file => {
|
||||
if (file.size > maxSize) {
|
||||
alert(`${file.name}: 파일 크기가 20MB를 초과합니다.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.totalProgress = 0;
|
||||
this.uploadStatus = `0 / ${validFiles.length} 파일 업로드 중...`;
|
||||
|
||||
let completed = 0;
|
||||
|
||||
for (const file of validFiles) {
|
||||
this.uploadStatus = `${completed + 1} / ${validFiles.length} 파일 업로드 중...`;
|
||||
|
||||
try {
|
||||
await this.uploadSingleFile(file, (progress) => {
|
||||
// 전체 진행률 계산
|
||||
const baseProgress = (completed / validFiles.length) * 100;
|
||||
const fileProgress = (progress / validFiles.length);
|
||||
this.totalProgress = Math.round(baseProgress + fileProgress);
|
||||
});
|
||||
|
||||
this.recentFiles.unshift({ name: file.name });
|
||||
if (this.recentFiles.length > 5) {
|
||||
this.recentFiles.pop();
|
||||
}
|
||||
completed++;
|
||||
this.uploadedCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error('파일 업로드 실패:', file.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.totalProgress = 100;
|
||||
this.uploadStatus = '업로드 완료!';
|
||||
|
||||
// 잠시 후 오버레이 닫기
|
||||
setTimeout(() => {
|
||||
this.uploading = false;
|
||||
this.totalProgress = 0;
|
||||
|
||||
// 상담 기록 새로고침
|
||||
htmx.ajax('GET',
|
||||
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}`,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
|
||||
// 5초 후 최근 파일 목록 초기화
|
||||
setTimeout(() => {
|
||||
this.recentFiles = [];
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
async uploadSingleFile(file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', this.tenantId);
|
||||
formData.append('scenario_type', this.scenarioType);
|
||||
if (this.stepId) formData.append('step_id', this.stepId);
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
if (result.success) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error(result.message || '업로드 실패'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('업로드 실패'));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
||||
|
||||
xhr.open('POST', '{{ route('sales.consultations.upload-file') }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,261 @@
|
||||
{{-- 계약 체결 시 상품 선택 컴포넌트 --}}
|
||||
@php
|
||||
use App\Models\Sales\SalesProductCategory;
|
||||
use App\Models\Sales\SalesContractProduct;
|
||||
|
||||
$categories = SalesProductCategory::active()
|
||||
->ordered()
|
||||
->with(['products' => fn($q) => $q->active()->ordered()])
|
||||
->get();
|
||||
|
||||
// 이미 선택된 상품들 조회
|
||||
$selectedProducts = SalesContractProduct::where('tenant_id', $tenant->id)
|
||||
->pluck('product_id')
|
||||
->toArray();
|
||||
|
||||
// 기존 계약 상품 정보 (가격 커스터마이징 포함)
|
||||
$contractProducts = SalesContractProduct::where('tenant_id', $tenant->id)
|
||||
->get()
|
||||
->keyBy('product_id');
|
||||
@endphp
|
||||
|
||||
<div x-data="productSelection()" class="mt-6 bg-gradient-to-br from-indigo-50 to-blue-50 rounded-xl p-5 border border-indigo-100">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-indigo-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-900">SAM 솔루션 상품 선택</h3>
|
||||
<p class="text-sm text-gray-600">고객사에 제공할 솔루션 패키지를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 카테고리 탭 --}}
|
||||
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
|
||||
@foreach($categories as $category)
|
||||
<button type="button"
|
||||
x-on:click="switchCategory('{{ $category->code }}')"
|
||||
:class="activeCategory === '{{ $category->code }}'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg whitespace-nowrap transition-colors">
|
||||
{{ $category->name }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 상품 목록 --}}
|
||||
@foreach($categories as $category)
|
||||
<div x-show="activeCategory === '{{ $category->code }}'" x-cloak>
|
||||
<div class="space-y-3">
|
||||
@foreach($category->products as $product)
|
||||
@php
|
||||
$isSelected = in_array($product->id, $selectedProducts);
|
||||
$contractProduct = $contractProducts->get($product->id);
|
||||
$devFee = $contractProduct?->development_fee ?? $product->development_fee;
|
||||
$subFee = $contractProduct?->subscription_fee ?? $product->subscription_fee;
|
||||
@endphp
|
||||
<div class="bg-white rounded-lg border transition-all"
|
||||
:class="selectedProducts.includes({{ $product->id }}) ? 'border-indigo-300 shadow-sm' : 'border-gray-200'">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
{{-- 체크박스 --}}
|
||||
<button type="button"
|
||||
x-on:click="toggleProduct({{ $product->id }}, {{ $product->category_id }}, {{ $product->registration_fee }}, {{ $product->subscription_fee }}, {{ $product->is_required ? 'true' : 'false' }})"
|
||||
:disabled="{{ $product->is_required ? 'true' : 'false' }}"
|
||||
class="flex-shrink-0 mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center transition-all"
|
||||
:class="selectedProducts.includes({{ $product->id }})
|
||||
? 'bg-indigo-600 border-indigo-600'
|
||||
: 'border-gray-300 hover:border-indigo-400'">
|
||||
<svg x-show="selectedProducts.includes({{ $product->id }})"
|
||||
class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-900">{{ $product->name }}</span>
|
||||
@if($product->is_required)
|
||||
<span class="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-600 rounded">필수</span>
|
||||
@endif
|
||||
@if($product->allow_flexible_pricing)
|
||||
<span class="px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-600 rounded">재량권</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($product->description)
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ $product->description }}</p>
|
||||
@endif
|
||||
<div class="flex items-center gap-4 mt-2 text-sm">
|
||||
<span class="text-gray-500">가입비:
|
||||
<span class="text-gray-400 line-through text-xs">{{ $product->formatted_development_fee }}</span>
|
||||
<span class="font-semibold text-indigo-600">{{ $product->formatted_registration_fee }}</span>
|
||||
</span>
|
||||
<span class="text-gray-500">월 구독료: <span class="font-semibold text-gray-900">{{ $product->formatted_subscription_fee }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- 합계 영역 --}}
|
||||
<div class="mt-4 pt-4 border-t border-indigo-200">
|
||||
<div class="bg-white rounded-lg p-4">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">선택 상품</p>
|
||||
<p class="text-xl font-bold text-gray-900" x-text="selectedCount + '개'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">총 가입비</p>
|
||||
<p class="text-xl font-bold text-indigo-600" x-text="formatCurrency(totalDevFee)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">월 구독료</p>
|
||||
<p class="text-xl font-bold text-green-600" x-text="formatCurrency(totalSubFee)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 저장 버튼 --}}
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button type="button"
|
||||
x-on:click="saveSelection()"
|
||||
:disabled="saving"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-text="saving ? '저장 중...' : '상품 선택 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function productSelection() {
|
||||
return {
|
||||
activeCategory: '{{ $categories->first()?->code ?? '' }}',
|
||||
selectedProducts: @json($selectedProducts),
|
||||
productData: {},
|
||||
categoryMap: {},
|
||||
totalDevFee: 0,
|
||||
totalSubFee: 0,
|
||||
selectedCount: 0,
|
||||
saving: false,
|
||||
|
||||
init() {
|
||||
// 카테고리 코드 → ID 매핑
|
||||
@foreach($categories as $category)
|
||||
this.categoryMap['{{ $category->code }}'] = {{ $category->id }};
|
||||
@endforeach
|
||||
|
||||
// 초기 데이터 설정
|
||||
@foreach($categories as $category)
|
||||
@foreach($category->products as $product)
|
||||
this.productData[{{ $product->id }}] = {
|
||||
categoryId: {{ $product->category_id }},
|
||||
categoryCode: '{{ $category->code }}',
|
||||
regFee: {{ $product->registration_fee }},
|
||||
subFee: {{ $product->subscription_fee }},
|
||||
isRequired: {{ $product->is_required ? 'true' : 'false' }},
|
||||
};
|
||||
@if($product->is_required && !in_array($product->id, $selectedProducts))
|
||||
this.selectedProducts.push({{ $product->id }});
|
||||
@endif
|
||||
@endforeach
|
||||
@endforeach
|
||||
this.calculateTotals();
|
||||
},
|
||||
|
||||
switchCategory(code) {
|
||||
this.activeCategory = code;
|
||||
this.calculateTotals();
|
||||
},
|
||||
|
||||
toggleProduct(productId, categoryId, regFee, subFee, isRequired) {
|
||||
if (isRequired) return; // 필수 상품은 토글 불가
|
||||
|
||||
const index = this.selectedProducts.indexOf(productId);
|
||||
if (index > -1) {
|
||||
this.selectedProducts.splice(index, 1);
|
||||
} else {
|
||||
this.selectedProducts.push(productId);
|
||||
}
|
||||
this.calculateTotals();
|
||||
},
|
||||
|
||||
calculateTotals() {
|
||||
this.totalDevFee = 0;
|
||||
this.totalSubFee = 0;
|
||||
this.selectedCount = 0;
|
||||
|
||||
// 현재 활성 카테고리의 상품만 합계 계산
|
||||
this.selectedProducts.forEach(id => {
|
||||
const product = this.productData[id];
|
||||
if (product && product.categoryCode === this.activeCategory) {
|
||||
this.totalDevFee += product.regFee;
|
||||
this.totalSubFee += product.subFee;
|
||||
this.selectedCount++;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
formatCurrency(value) {
|
||||
return '₩' + Number(value).toLocaleString();
|
||||
},
|
||||
|
||||
async saveSelection() {
|
||||
this.saving = true;
|
||||
try {
|
||||
// 현재 카테고리의 선택된 상품만 저장
|
||||
const currentCategoryId = this.categoryMap[this.activeCategory];
|
||||
const products = this.selectedProducts
|
||||
.filter(id => this.productData[id].categoryCode === this.activeCategory)
|
||||
.map(id => ({
|
||||
product_id: id,
|
||||
category_id: this.productData[id].categoryId,
|
||||
registration_fee: this.productData[id].regFee,
|
||||
subscription_fee: this.productData[id].subFee,
|
||||
}));
|
||||
|
||||
const response = await fetch('/sales/contracts/products', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: {{ $tenant->id }},
|
||||
category_id: currentCategoryId,
|
||||
products: products,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert('상품 선택이 저장되었습니다.');
|
||||
} else {
|
||||
alert(result.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
270
resources/views/sales/modals/scenario-modal.blade.php
Normal file
270
resources/views/sales/modals/scenario-modal.blade.php
Normal file
@@ -0,0 +1,270 @@
|
||||
{{-- 영업/매니저 시나리오 모달 --}}
|
||||
@php
|
||||
$stepProgressJson = json_encode($progress['steps'] ?? []);
|
||||
@endphp
|
||||
|
||||
<div x-data="{
|
||||
isOpen: true,
|
||||
currentStep: {{ $currentStep }},
|
||||
totalProgress: {{ $progress['percentage'] ?? 0 }},
|
||||
stepProgress: {{ $stepProgressJson }},
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
window.dispatchEvent(new CustomEvent('scenario-modal-closed', {
|
||||
detail: {
|
||||
tenantId: this.tenantId,
|
||||
scenarioType: this.scenarioType,
|
||||
progress: this.totalProgress
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
completeAndRefresh(detail) {
|
||||
this.isOpen = false;
|
||||
// 테넌트 리스트 새로고침 (진행률 반영)
|
||||
htmx.ajax('GET', '{{ route("sales.salesmanagement.dashboard.tenants") }}', { target: '#tenant-list-container', swap: 'innerHTML' });
|
||||
// 모달 닫힘 이벤트 발송
|
||||
window.dispatchEvent(new CustomEvent('scenario-modal-closed', {
|
||||
detail: {
|
||||
tenantId: detail.tenantId,
|
||||
scenarioType: detail.scenarioType,
|
||||
progress: this.totalProgress,
|
||||
completed: true
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
selectStep(stepId) {
|
||||
if (this.currentStep === stepId) return;
|
||||
this.currentStep = stepId;
|
||||
htmx.ajax('GET',
|
||||
`/sales/scenarios/${this.tenantId}/${this.scenarioType}?step=${stepId}`,
|
||||
{ target: '#scenario-step-content', swap: 'innerHTML' }
|
||||
);
|
||||
},
|
||||
|
||||
async toggleCheckpoint(stepId, checkpointId, checked) {
|
||||
try {
|
||||
const response = await fetch('/sales/scenarios/checklist/toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: this.tenantId,
|
||||
scenario_type: this.scenarioType,
|
||||
step_id: stepId,
|
||||
checkpoint_id: checkpointId,
|
||||
checked: checked,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.totalProgress = result.progress.percentage;
|
||||
this.stepProgress = result.progress.steps;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('체크리스트 토글 실패:', error);
|
||||
}
|
||||
}
|
||||
}"
|
||||
x-show="isOpen"
|
||||
x-cloak
|
||||
@keydown.escape.window="close()"
|
||||
@progress-updated.window="totalProgress = $event.detail.percentage; stepProgress = $event.detail.steps"
|
||||
@step-changed.window="currentStep = $event.detail"
|
||||
@scenario-completed.window="completeAndRefresh($event.detail)"
|
||||
class="fixed inset-0 z-50 overflow-hidden"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
|
||||
{{-- 배경 오버레이 --}}
|
||||
<div class="absolute inset-0 bg-gray-900/50 backdrop-blur-sm" @click="close()"></div>
|
||||
|
||||
{{-- 모달 컨테이너 --}}
|
||||
<div class="relative flex items-center justify-center min-h-screen p-4">
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.stop>
|
||||
|
||||
{{-- 모달 헤더 --}}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 {{ $scenarioType === 'sales' ? 'bg-blue-600' : 'bg-green-600' }}">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-2 bg-white/20 rounded-lg">
|
||||
@if($scenarioType === 'sales')
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{{ $scenarioType === 'sales' ? '영업 전략 시나리오' : '매니저 상담 프로세스' }}
|
||||
</h2>
|
||||
<p class="text-sm text-white/80">{{ $tenant->company_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{{-- 전체 진행률 --}}
|
||||
<div class="flex items-center gap-2 bg-white/20 rounded-full px-4 py-2">
|
||||
<span class="text-sm font-medium text-white">진행률</span>
|
||||
<span class="text-lg font-bold text-white" x-text="totalProgress + '%'"></span>
|
||||
</div>
|
||||
{{-- 닫기 버튼 --}}
|
||||
<button type="button" @click="close()" class="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors">
|
||||
<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>
|
||||
|
||||
{{-- 모달 바디 --}}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
{{-- 좌측 사이드바: 단계 네비게이션 --}}
|
||||
<div class="w-64 bg-gray-50 border-r border-gray-200 overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">단계별 진행</h3>
|
||||
<nav class="space-y-2">
|
||||
@foreach($steps as $step)
|
||||
<button type="button"
|
||||
@click="selectStep({{ $step['id'] }})"
|
||||
:class="currentStep === {{ $step['id'] }}
|
||||
? 'bg-white shadow-sm border-l-4 border-{{ $step['color'] }}-500'
|
||||
: 'hover:bg-white/50 border-l-4 border-transparent'"
|
||||
class="w-full flex items-center gap-3 px-3 py-3 rounded-r-lg text-left transition-all">
|
||||
<div class="{{ $step['bg_class'] }} {{ $step['text_class'] }} p-2 rounded-lg">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{!! $icons[$step['icon']] ?? '' !!}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 truncate">{{ $step['title'] }}</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
x-text="(stepProgress[{{ $step['id'] }}]?.percentage ?? 0) + '%'"></span>
|
||||
</div>
|
||||
<div class="mt-1 h-1 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-{{ $step['color'] }}-500 transition-all duration-300"
|
||||
:style="'width: ' + (stepProgress[{{ $step['id'] }}]?.percentage ?? 0) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 우측 메인 영역 --}}
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
{{-- 단계별 콘텐츠 (스크롤 가능) --}}
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<div id="scenario-step-content" class="p-6">
|
||||
@include('sales.modals.scenario-step', [
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'steps' => $steps,
|
||||
'tenant' => $tenant,
|
||||
'scenarioType' => $scenarioType,
|
||||
'progress' => $progress,
|
||||
'icons' => $icons,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 하단 고정: 상담 기록 및 첨부파일 (모든 단계 공유) --}}
|
||||
<div x-data="{ consultationExpanded: false }" class="flex-shrink-0 border-t border-gray-200 bg-gray-50">
|
||||
{{-- 아코디언 헤더 --}}
|
||||
<button type="button"
|
||||
@click="consultationExpanded = !consultationExpanded"
|
||||
class="w-full flex items-center justify-between px-6 py-3 hover:bg-gray-100 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-purple-100 rounded-lg">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-gray-900">상담 기록 및 첨부파일</h3>
|
||||
<p class="text-xs text-gray-500">음성 녹음, 메모, 파일 첨부 (모든 단계 공유)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400" x-text="consultationExpanded ? '접기' : '펼치기'"></span>
|
||||
<svg class="w-5 h-5 text-gray-400 transition-transform duration-200"
|
||||
:class="consultationExpanded && 'rotate-180'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{{-- 아코디언 콘텐츠 --}}
|
||||
<div x-show="consultationExpanded"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="overflow-y-auto bg-white border-t border-gray-200"
|
||||
style="max-height: 50vh;">
|
||||
<div class="p-6 space-y-4">
|
||||
{{-- 상담 기록 --}}
|
||||
<div id="consultation-log-container"
|
||||
hx-get="{{ route('sales.consultations.index', $tenant->id) }}?scenario_type={{ $scenarioType }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse flex space-x-4">
|
||||
<div class="flex-1 space-y-4 py-1">
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 bg-gray-200 rounded"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 음성 녹음 --}}
|
||||
<div>
|
||||
@include('sales.modals.voice-recorder', [
|
||||
'tenant' => $tenant,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => null,
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- 첨부파일 업로드 --}}
|
||||
<div>
|
||||
@include('sales.modals.file-uploader', [
|
||||
'tenant' => $tenant,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => null,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
208
resources/views/sales/modals/scenario-step.blade.php
Normal file
208
resources/views/sales/modals/scenario-step.blade.php
Normal file
@@ -0,0 +1,208 @@
|
||||
{{-- 시나리오 단계별 체크리스트 --}}
|
||||
@php
|
||||
use App\Models\Sales\SalesScenarioChecklist;
|
||||
|
||||
// $steps가 없거나 비어있으면 config에서 가져오기 (안전장치)
|
||||
if (empty($steps)) {
|
||||
$steps = config($scenarioType === 'sales' ? 'sales_scenario.sales_steps' : 'sales_scenario.manager_steps', []);
|
||||
}
|
||||
$step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1);
|
||||
// DB에서 체크된 항목 조회
|
||||
$checklist = SalesScenarioChecklist::getChecklist($tenant->id, $scenarioType);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- 단계 헤더 --}}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="{{ $step['bg_class'] }} {{ $step['text_class'] }} p-3 rounded-xl">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{!! $icons[$step['icon']] ?? '' !!}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-500">STEP {{ $step['id'] }}</span>
|
||||
<span class="text-sm text-gray-400">{{ $step['subtitle'] }}</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">{{ $step['title'] }}</h2>
|
||||
<p class="mt-1 text-gray-600">{{ $step['description'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 매니저용 팁 (있는 경우) --}}
|
||||
@if(isset($step['tips']))
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-800">매니저 TIP</p>
|
||||
<p class="text-sm text-amber-700">{{ $step['tips'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 체크포인트 목록 --}}
|
||||
<div class="space-y-4">
|
||||
@foreach($step['checkpoints'] as $checkpoint)
|
||||
@php
|
||||
$checkKey = "{$step['id']}_{$checkpoint['id']}";
|
||||
$isChecked = isset($checklist[$checkKey]);
|
||||
@endphp
|
||||
<div x-data="{
|
||||
expanded: false,
|
||||
checked: {{ $isChecked ? 'true' : 'false' }},
|
||||
async toggle() {
|
||||
this.checked = !this.checked;
|
||||
try {
|
||||
const response = await fetch('/sales/scenarios/checklist/toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: {{ $tenant->id }},
|
||||
scenario_type: '{{ $scenarioType }}',
|
||||
step_id: {{ $step['id'] }},
|
||||
checkpoint_id: '{{ $checkpoint['id'] }}',
|
||||
checked: this.checked,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
window.dispatchEvent(new CustomEvent('progress-updated', { detail: result.progress }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('체크리스트 토글 실패:', error);
|
||||
this.checked = !this.checked;
|
||||
}
|
||||
}
|
||||
}"
|
||||
class="bg-white border rounded-xl overflow-hidden transition-all duration-200"
|
||||
:class="checked ? 'border-green-300 bg-green-50/50' : 'border-gray-200 hover:border-gray-300'">
|
||||
|
||||
{{-- 체크포인트 헤더 --}}
|
||||
<div class="flex items-center gap-4 p-4 cursor-pointer" @click="expanded = !expanded">
|
||||
{{-- 체크박스 --}}
|
||||
<button type="button"
|
||||
@click.stop="toggle()"
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all"
|
||||
:class="checked ? 'bg-green-500 border-green-500' : 'border-gray-300 hover:border-green-400'">
|
||||
<svg x-show="checked" class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- 제목 및 설명 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-gray-900" :class="checked && 'line-through text-gray-500'">
|
||||
{{ $checkpoint['title'] }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 truncate">{{ $checkpoint['detail'] }}</p>
|
||||
</div>
|
||||
|
||||
{{-- 확장 아이콘 --}}
|
||||
<svg class="w-5 h-5 text-gray-400 transition-transform duration-200"
|
||||
:class="expanded && 'rotate-180'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- 확장 콘텐츠 --}}
|
||||
<div x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="border-t border-gray-100">
|
||||
<div class="p-4 space-y-4">
|
||||
{{-- 상세 설명 --}}
|
||||
<div>
|
||||
<h5 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">상세 설명</h5>
|
||||
<p class="text-sm text-gray-700">{{ $checkpoint['detail'] }}</p>
|
||||
</div>
|
||||
|
||||
{{-- PRO TIP --}}
|
||||
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-1.5 bg-indigo-100 rounded-lg">
|
||||
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-indigo-800 uppercase">PRO TIP</p>
|
||||
<p class="text-sm text-indigo-700 mt-1">{{ $checkpoint['pro_tip'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 계약 체결 단계 (Step 6)에서만 상품 선택 컴포넌트 표시 --}}
|
||||
@if($step['id'] === 6 && $scenarioType === 'sales')
|
||||
@include('sales.modals.partials.product-selection', ['tenant' => $tenant])
|
||||
@endif
|
||||
|
||||
{{-- 단계 이동 버튼 --}}
|
||||
@php
|
||||
$currentStepId = (int) $step['id'];
|
||||
$totalSteps = count($steps);
|
||||
$isLastStep = ($currentStepId >= $totalSteps);
|
||||
$nextStepId = $currentStepId + 1;
|
||||
$prevStepId = $currentStepId - 1;
|
||||
$stepColor = $step['color'] ?? 'blue';
|
||||
@endphp
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
{{-- 이전 단계 버튼 --}}
|
||||
@if($currentStepId > 1)
|
||||
<button type="button"
|
||||
hx-get="{{ route('sales.scenarios.' . $scenarioType, $tenant->id) }}?step={{ $prevStepId }}"
|
||||
hx-target="#scenario-step-content"
|
||||
hx-swap="innerHTML"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $prevStepId }} }))"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
이전 단계
|
||||
</button>
|
||||
@else
|
||||
<div></div>
|
||||
@endif
|
||||
|
||||
{{-- 다음 단계 / 완료 버튼 --}}
|
||||
@if($isLastStep)
|
||||
<button type="button"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('scenario-completed', { detail: { tenantId: {{ $tenant->id }}, scenarioType: '{{ $scenarioType }}' } }))"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
완료
|
||||
</button>
|
||||
@else
|
||||
<button type="button"
|
||||
hx-get="{{ route('sales.scenarios.' . $scenarioType, $tenant->id) }}?step={{ $nextStepId }}"
|
||||
hx-target="#scenario-step-content"
|
||||
hx-swap="innerHTML"
|
||||
x-on:click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $nextStepId }} }))"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
다음 단계
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
399
resources/views/sales/modals/voice-recorder.blade.php
Normal file
399
resources/views/sales/modals/voice-recorder.blade.php
Normal file
@@ -0,0 +1,399 @@
|
||||
{{-- 음성 녹음 컴포넌트 (자동 저장) --}}
|
||||
<div x-data="{
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
|
||||
isRecording: false,
|
||||
audioBlob: null,
|
||||
timer: 0,
|
||||
transcript: '',
|
||||
interimTranscript: '',
|
||||
status: '마이크 버튼을 눌러 녹음을 시작하세요',
|
||||
saving: false,
|
||||
saveProgress: 0,
|
||||
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
timerInterval: null,
|
||||
recognition: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
analyser: null,
|
||||
animationId: null,
|
||||
expanded: false,
|
||||
|
||||
formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
|
||||
},
|
||||
|
||||
async toggleRecording() {
|
||||
if (this.isRecording) {
|
||||
await this.stopRecording();
|
||||
} else {
|
||||
await this.startRecording();
|
||||
}
|
||||
},
|
||||
|
||||
async startRecording() {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.status = '녹음 중...';
|
||||
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
const source = this.audioContext.createMediaStreamSource(this.stream);
|
||||
source.connect(this.analyser);
|
||||
this.analyser.fftSize = 2048;
|
||||
|
||||
this.drawWaveform();
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.audioChunks = [];
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = async () => {
|
||||
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
// 녹음 중지 후 자동 저장
|
||||
await this.saveRecording();
|
||||
};
|
||||
|
||||
this.mediaRecorder.start();
|
||||
|
||||
this.timer = 0;
|
||||
this.timerInterval = setInterval(() => {
|
||||
this.timer++;
|
||||
}, 1000);
|
||||
|
||||
this.startSpeechRecognition();
|
||||
this.isRecording = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('녹음 시작 실패:', error);
|
||||
this.status = '마이크 접근 권한이 필요합니다.';
|
||||
}
|
||||
},
|
||||
|
||||
async stopRecording() {
|
||||
if (this.timerInterval) {
|
||||
clearInterval(this.timerInterval);
|
||||
this.timerInterval = null;
|
||||
}
|
||||
if (this.recognition) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
}
|
||||
this.isRecording = false;
|
||||
|
||||
// mediaRecorder.stop()을 호출하면 onstop 이벤트에서 자동 저장됨
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
},
|
||||
|
||||
startSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
console.warn('음성 인식이 지원되지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.lang = 'ko-KR';
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.maxAlternatives = 1;
|
||||
|
||||
this.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
|
||||
let confirmedResults = [];
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
let interimTranscript = '';
|
||||
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
const text = result[0].transcript;
|
||||
|
||||
if (result.isFinal) {
|
||||
if (!confirmedResults[i]) {
|
||||
confirmedResults[i] = text;
|
||||
}
|
||||
} else {
|
||||
interimTranscript += text;
|
||||
}
|
||||
}
|
||||
|
||||
this.transcript = confirmedResults.filter(Boolean).join(' ');
|
||||
this.interimTranscript = interimTranscript;
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
if (event.error === 'no-speech' || event.error === 'aborted') {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
if (this.isRecording && this.recognition) {
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
this.recognition.start();
|
||||
},
|
||||
|
||||
drawWaveform() {
|
||||
if (!this.analyser || !this.$refs.waveformCanvas) return;
|
||||
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
const draw = () => {
|
||||
if (!this.isRecording) return;
|
||||
|
||||
this.analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#9333ea';
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = canvas.width / dataArray.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = dataArray[i] / 128.0;
|
||||
const y = v * canvas.height / 2;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
ctx.lineTo(canvas.width, canvas.height / 2);
|
||||
ctx.stroke();
|
||||
|
||||
this.animationId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
},
|
||||
|
||||
async saveRecording() {
|
||||
if (!this.audioBlob || this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
this.saveProgress = 0;
|
||||
this.status = '녹음 파일 저장 중...';
|
||||
|
||||
// 프로그레스 시뮬레이션 시작
|
||||
const progressInterval = setInterval(() => {
|
||||
if (this.saveProgress < 90) {
|
||||
this.saveProgress += Math.random() * 15;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', this.tenantId);
|
||||
formData.append('scenario_type', this.scenarioType);
|
||||
if (this.stepId) formData.append('step_id', this.stepId);
|
||||
formData.append('audio', this.audioBlob, 'recording.webm');
|
||||
formData.append('transcript', this.transcript);
|
||||
formData.append('duration', this.timer);
|
||||
|
||||
const response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
this.saveProgress = 100;
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.status = '저장 완료!' + (result.consultation.has_gcs ? ' (GCS 백업됨)' : '');
|
||||
|
||||
// 잠시 후 초기화
|
||||
setTimeout(() => {
|
||||
this.resetRecording();
|
||||
// 상담 기록 컨테이너 갱신
|
||||
htmx.ajax('GET',
|
||||
'/sales/consultations/' + this.tenantId + '?scenario_type=' + this.scenarioType,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
}, 1000);
|
||||
} else {
|
||||
this.status = '저장 실패. 다시 시도해주세요.';
|
||||
this.saving = false;
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
console.error('녹음 저장 실패:', error);
|
||||
this.status = '저장 중 오류 발생';
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetRecording() {
|
||||
this.audioBlob = null;
|
||||
this.timer = 0;
|
||||
this.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
this.saving = false;
|
||||
this.saveProgress = 0;
|
||||
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
|
||||
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
}" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
|
||||
|
||||
{{-- 저장 중 오버레이 --}}
|
||||
<div x-show="saving"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="absolute inset-0 z-10 bg-white/95 flex flex-col items-center justify-center">
|
||||
<div class="w-48 space-y-4">
|
||||
{{-- 아이콘 --}}
|
||||
<div class="flex justify-center">
|
||||
<div class="p-4 bg-purple-100 rounded-full">
|
||||
<svg class="w-8 h-8 text-purple-600 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 텍스트 --}}
|
||||
<p class="text-center text-sm font-medium text-gray-700" x-text="status"></p>
|
||||
{{-- 프로그레스바 --}}
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-purple-600 transition-all duration-300 ease-out rounded-full"
|
||||
:style="'width: ' + Math.min(saveProgress, 100) + '%'"></div>
|
||||
</div>
|
||||
<p class="text-center text-xs text-gray-500">잠시만 기다려주세요...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 헤더 (접기/펼치기) --}}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
:disabled="saving"
|
||||
class="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors disabled:opacity-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700">음성 녹음</span>
|
||||
<span x-show="isRecording" class="flex items-center gap-1 px-2 py-0.5 bg-red-100 text-red-600 text-xs font-medium rounded-full">
|
||||
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
|
||||
녹음 중
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400" x-text="expanded ? '접기' : '펼치기'"></span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
|
||||
:class="expanded && 'rotate-180'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{{-- 녹음 컨트롤 (접기/펼치기) --}}
|
||||
<div x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="px-4 pb-4 space-y-4 border-t border-gray-100">
|
||||
{{-- 파형 시각화 --}}
|
||||
<div class="relative mt-4">
|
||||
<canvas
|
||||
x-ref="waveformCanvas"
|
||||
class="w-full h-20 bg-gray-50 rounded-lg border border-gray-200"
|
||||
></canvas>
|
||||
{{-- 타이머 오버레이 --}}
|
||||
<div x-show="isRecording" class="absolute top-2 right-2 flex items-center gap-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-medium">
|
||||
<span class="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||
<span x-text="formatTime(timer)">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 실시간 텍스트 변환 표시 --}}
|
||||
<div x-show="transcript || interimTranscript" class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500">음성 인식 결과</p>
|
||||
<p class="text-xs text-gray-400" x-text="transcript.length + ' 자'"></p>
|
||||
</div>
|
||||
<div class="p-3 max-h-32 overflow-y-auto" x-ref="transcriptContainer" x-effect="if(transcript || interimTranscript) { $nextTick(() => $refs.transcriptContainer.scrollTop = $refs.transcriptContainer.scrollHeight) }">
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
|
||||
<span x-text="transcript"></span>
|
||||
<span class="text-gray-400 italic" x-text="interimTranscript"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 녹음 버튼 --}}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
@click="toggleRecording()"
|
||||
:disabled="saving"
|
||||
:class="isRecording ? 'bg-red-500 hover:bg-red-600 ring-4 ring-red-200' : 'bg-purple-600 hover:bg-purple-700'"
|
||||
class="flex items-center justify-center w-16 h-16 rounded-full text-white shadow-lg transition-all transform hover:scale-105 active:scale-95 disabled:opacity-50">
|
||||
<svg x-show="!isRecording" class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
<svg x-show="isRecording" class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<p class="text-sm text-gray-600" x-text="isRecording ? '녹음을 중지하려면 버튼을 누르세요' : '버튼을 눌러 녹음 시작'"></p>
|
||||
</div>
|
||||
|
||||
{{-- 안내 문구 --}}
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
녹음 종료 시 자동 저장됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
418
resources/views/sales/products/index.blade.php
Normal file
418
resources/views/sales/products/index.blade.php
Normal file
@@ -0,0 +1,418 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '상품관리')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8" x-data="productManager()">
|
||||
{{-- 헤더 --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 bg-indigo-100 rounded-xl">
|
||||
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">상품관리</h1>
|
||||
<p class="text-sm text-gray-500">SAM 솔루션 상품 및 요금 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
x-on:click="openCategoryModal()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
카테고리 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 카테고리 탭 --}}
|
||||
<div class="bg-white rounded-xl shadow-sm">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex -mb-px px-4" aria-label="카테고리">
|
||||
@foreach($categories as $category)
|
||||
<button type="button"
|
||||
x-on:click="selectCategory('{{ $category->code }}')"
|
||||
:class="currentCategory === '{{ $category->code }}'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-6 border-b-2 font-medium text-sm transition-colors">
|
||||
{{ $category->name }}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full"
|
||||
:class="currentCategory === '{{ $category->code }}' ? 'bg-indigo-100 text-indigo-600' : 'bg-gray-100 text-gray-500'">
|
||||
{{ $category->products->count() }}
|
||||
</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{-- 상품 목록 영역 --}}
|
||||
<div class="p-6">
|
||||
{{-- 헤더 --}}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold text-gray-900" x-text="categoryName"></span>
|
||||
<span class="text-sm text-gray-500">(기본 제공: <span x-text="baseStorage"></span>)</span>
|
||||
</div>
|
||||
<button type="button"
|
||||
x-on:click="openProductModal()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
상품 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 상품 카드 그리드 --}}
|
||||
<div id="product-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@include('sales.products.partials.product-list', ['category' => $currentCategory])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 상품 추가/수정 모달 --}}
|
||||
<div x-show="showProductModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
{{-- 배경 오버레이 (클릭해도 닫히지 않음) --}}
|
||||
<div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm"></div>
|
||||
<div class="min-h-screen px-4 flex items-center justify-center relative">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 relative">
|
||||
<button type="button" x-on:click="showProductModal = false"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4" x-text="editingProduct ? '상품 수정' : '상품 추가'"></h3>
|
||||
<form x-on:submit.prevent="saveProduct()">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상품 코드</label>
|
||||
<input type="text" x-model="productForm.code" :disabled="editingProduct"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-100"
|
||||
placeholder="basic, quality, ai 등" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상품명</label>
|
||||
<input type="text" x-model="productForm.name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="기본형, 품질관리 등" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<textarea x-model="productForm.description" rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="프로그램 타입 및 포함 기능"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">개발비 (원가)</label>
|
||||
<input type="text"
|
||||
:value="formatNumber(productForm.development_fee)"
|
||||
x-on:input="updateDevelopmentFee($event.target.value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
|
||||
placeholder="0" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">가입비 (할인가)</label>
|
||||
<input type="text"
|
||||
:value="formatNumber(productForm.registration_fee)"
|
||||
x-on:input="productForm.registration_fee = parseNumber($event.target.value); $event.target.value = formatNumber(productForm.registration_fee)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right bg-indigo-50"
|
||||
placeholder="0" required>
|
||||
<p class="text-xs text-gray-500 mt-1">기본: 개발비의 25%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">월 구독료</label>
|
||||
<input type="text"
|
||||
:value="formatNumber(productForm.subscription_fee)"
|
||||
x-on:input="productForm.subscription_fee = parseNumber($event.target.value); $event.target.value = formatNumber(productForm.subscription_fee)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
|
||||
placeholder="0" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">영업파트너 수당 (%)</label>
|
||||
<input type="number" x-model="productForm.partner_commission_rate"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
|
||||
min="0" max="100" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">매니저 수당 (%)</label>
|
||||
<input type="number" x-model="productForm.manager_commission_rate"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
|
||||
min="0" max="100" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="productForm.is_required"
|
||||
class="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="text-sm text-gray-700">필수 상품</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="productForm.allow_flexible_pricing"
|
||||
class="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="text-sm text-gray-700">재량권 허용</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" x-on:click="showProductModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 카테고리 관리 모달 --}}
|
||||
<div x-show="showCategoryModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
{{-- 배경 오버레이 (클릭해도 닫히지 않음) --}}
|
||||
<div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm"></div>
|
||||
<div class="min-h-screen px-4 flex items-center justify-center relative">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6 relative">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">카테고리 관리</h3>
|
||||
<div class="space-y-3 mb-4">
|
||||
@foreach($categories as $category)
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ $category->name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $category->code }} / {{ $category->base_storage }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">{{ $category->products->count() }}개 상품</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">새 카테고리 추가</h4>
|
||||
<div class="space-y-3">
|
||||
<input type="text" x-model="categoryForm.code" placeholder="코드 (영문)"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<input type="text" x-model="categoryForm.name" placeholder="카테고리명"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<button type="button" x-on:click="saveCategory()"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" x-on:click="showCategoryModal = false"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function productManager() {
|
||||
return {
|
||||
currentCategory: '{{ $currentCategory?->code ?? '' }}',
|
||||
categoryName: '{{ $currentCategory?->name ?? '' }}',
|
||||
baseStorage: '{{ $currentCategory?->base_storage ?? '100GB' }}',
|
||||
categories: @json($categories),
|
||||
|
||||
showProductModal: false,
|
||||
showCategoryModal: false,
|
||||
editingProduct: null,
|
||||
|
||||
productForm: {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
development_fee: 0,
|
||||
registration_fee: 0,
|
||||
subscription_fee: 0,
|
||||
partner_commission_rate: 20,
|
||||
manager_commission_rate: 5,
|
||||
is_required: false,
|
||||
allow_flexible_pricing: true,
|
||||
},
|
||||
|
||||
categoryForm: {
|
||||
code: '',
|
||||
name: '',
|
||||
},
|
||||
|
||||
selectCategory(code) {
|
||||
this.currentCategory = code;
|
||||
const cat = this.categories.find(c => c.code === code);
|
||||
if (cat) {
|
||||
this.categoryName = cat.name;
|
||||
this.baseStorage = cat.base_storage;
|
||||
}
|
||||
htmx.ajax('GET', '{{ route("sales.products.list") }}?category=' + code, {
|
||||
target: '#product-list',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
},
|
||||
|
||||
openProductModal(product = null) {
|
||||
this.editingProduct = product;
|
||||
if (product) {
|
||||
this.productForm = { ...product };
|
||||
} else {
|
||||
this.productForm = {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
development_fee: 0,
|
||||
registration_fee: 0,
|
||||
subscription_fee: 0,
|
||||
partner_commission_rate: 20,
|
||||
manager_commission_rate: 5,
|
||||
is_required: false,
|
||||
allow_flexible_pricing: true,
|
||||
};
|
||||
}
|
||||
this.showProductModal = true;
|
||||
},
|
||||
|
||||
async saveProduct() {
|
||||
const cat = this.categories.find(c => c.code === this.currentCategory);
|
||||
if (!cat) return;
|
||||
|
||||
const url = this.editingProduct
|
||||
? '{{ url("sales/products") }}/' + this.editingProduct.id
|
||||
: '{{ route("sales.products.store") }}';
|
||||
const method = this.editingProduct ? 'PUT' : 'POST';
|
||||
|
||||
const data = {
|
||||
...this.productForm,
|
||||
category_id: cat.id,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.showProductModal = false;
|
||||
this.selectCategory(this.currentCategory);
|
||||
} else {
|
||||
alert(result.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteProduct(id) {
|
||||
if (!confirm('이 상품을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url("sales/products") }}/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.selectCategory(this.currentCategory);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
openCategoryModal() {
|
||||
this.categoryForm = { code: '', name: '' };
|
||||
this.showCategoryModal = true;
|
||||
},
|
||||
|
||||
async saveCategory() {
|
||||
if (!this.categoryForm.code || !this.categoryForm.name) {
|
||||
alert('코드와 이름을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("sales.products.categories.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
},
|
||||
body: JSON.stringify(this.categoryForm),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
formatCurrency(value) {
|
||||
return '₩' + Number(value).toLocaleString();
|
||||
},
|
||||
|
||||
formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return Math.floor(Number(value)).toLocaleString('ko-KR');
|
||||
},
|
||||
|
||||
parseNumber(value) {
|
||||
if (!value) return 0;
|
||||
return parseInt(String(value).replace(/[^\d]/g, ''), 10) || 0;
|
||||
},
|
||||
|
||||
updateDevelopmentFee(value) {
|
||||
const fee = this.parseNumber(value);
|
||||
this.productForm.development_fee = fee;
|
||||
// 가입비 자동 계산 (개발비의 25%)
|
||||
this.productForm.registration_fee = Math.floor(fee * 0.25);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -0,0 +1,84 @@
|
||||
@if($category && $category->products->count() > 0)
|
||||
@foreach($category->products as $product)
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow {{ !$product->is_active ? 'opacity-60' : '' }}">
|
||||
{{-- 헤더 --}}
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold text-gray-900">{{ $product->name }}</h4>
|
||||
@if($product->is_required)
|
||||
<span class="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-600 rounded">필수</span>
|
||||
@endif
|
||||
@if(!$product->is_active)
|
||||
<span class="px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 rounded">비활성</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ $product->code }}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
x-on:click="openProductModal({{ $product->toJson() }})"
|
||||
class="p-1 text-gray-400 hover:text-indigo-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 설명 --}}
|
||||
@if($product->description)
|
||||
<p class="text-sm text-gray-600 mb-3 line-clamp-2">{{ $product->description }}</p>
|
||||
@endif
|
||||
|
||||
{{-- 가격 정보 --}}
|
||||
<div class="space-y-2 pt-3 border-t border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">가입비</span>
|
||||
<div class="text-right">
|
||||
<span class="text-sm text-gray-400 line-through">{{ $product->formatted_development_fee }}</span>
|
||||
<span class="ml-2 font-bold text-indigo-600">{{ $product->formatted_registration_fee }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">월 구독료</span>
|
||||
<span class="font-semibold text-gray-900">{{ $product->formatted_subscription_fee }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">수당</span>
|
||||
<span class="text-sm text-green-600">
|
||||
<span class="font-medium">파트너</span> {{ number_format($product->partner_commission_rate, 0) }}%
|
||||
<span class="text-gray-300 mx-1">|</span>
|
||||
<span class="font-medium">매니저</span> {{ number_format($product->manager_commission_rate, 0) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 하단 태그 --}}
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($product->allow_flexible_pricing)
|
||||
<span class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">재량권 허용</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded-full">고정가</span>
|
||||
@endif
|
||||
</div>
|
||||
<button type="button"
|
||||
x-on:click="deleteProduct({{ $product->id }})"
|
||||
class="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
<div class="col-span-full text-center py-12">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-600 font-medium mb-1">등록된 상품이 없습니다</p>
|
||||
<p class="text-sm text-gray-400">상품 추가 버튼을 클릭하여 새 상품을 등록하세요</p>
|
||||
</div>
|
||||
@endif
|
||||
@@ -99,11 +99,23 @@
|
||||
|
||||
<div class="ocr-drop-zone" id="ocr-drop-zone">
|
||||
<input type="file" id="ocr-file-input" accept="image/*" class="hidden">
|
||||
<input type="file" id="ocr-camera-input" accept="image/*" capture="environment" class="hidden">
|
||||
<svg class="ocr-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-600 font-medium text-sm">명함 이미지를 드래그하거나 클릭하여 업로드</p>
|
||||
<p class="text-gray-400 text-xs mt-1">AI가 자동으로 정보를 추출합니다 (JPG, PNG)</p>
|
||||
|
||||
<!-- 모바일 카메라 촬영 버튼 -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<button type="button" id="ocr-camera-btn" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition">
|
||||
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
카메라로 촬영
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<img id="ocr-preview-image" alt="Preview">
|
||||
</div>
|
||||
@@ -112,8 +124,6 @@
|
||||
<form action="{{ route('sales.prospects.store') }}" method="POST" enctype="multipart/form-data" class="bg-white rounded-lg shadow-sm p-6 space-y-6">
|
||||
@csrf
|
||||
<input type="hidden" name="business_card_image_data" id="business_card_image_data" value="">
|
||||
<input type="hidden" name="id_card_image_data" id="id_card_image_data" value="">
|
||||
<input type="hidden" name="bankbook_image_data" id="bankbook_image_data" value="">
|
||||
|
||||
<!-- 사업자번호 (중복 체크) -->
|
||||
<div>
|
||||
@@ -170,44 +180,6 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">명함 이미지</label>
|
||||
<input type="file" name="business_card" id="business_card_file" accept="image/*"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
|
||||
</div>
|
||||
|
||||
<!-- 추가 첨부파일 -->
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">추가 서류</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">신분증 사본</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center cursor-pointer hover:border-blue-400 transition" id="id-card-drop-zone">
|
||||
<input type="file" name="id_card" id="id_card_file" accept="image/*" class="hidden">
|
||||
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500">클릭하여 업로드</p>
|
||||
</div>
|
||||
<img id="id-card-preview" class="mt-2 max-h-32 rounded-lg hidden" alt="신분증 미리보기">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">통장 사본</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center cursor-pointer hover:border-blue-400 transition" id="bankbook-drop-zone">
|
||||
<input type="file" name="bankbook" id="bankbook_file" accept="image/*" class="hidden">
|
||||
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500">클릭하여 업로드</p>
|
||||
</div>
|
||||
<img id="bankbook-preview" class="mt-2 max-h-32 rounded-lg hidden" alt="통장사본 미리보기">
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">메모</label>
|
||||
<textarea name="memo" rows="3"
|
||||
@@ -219,8 +191,8 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>등록일로부터 <strong>2개월간</strong> 영업권이 유효합니다.</li>
|
||||
<li>유효기간 내 테넌트로 전환 시 영업 실적으로 인정됩니다.</li>
|
||||
<li>만료 후 <strong>1개월간</strong> 쿨다운 기간이 적용됩니다.</li>
|
||||
<li>쿨다운 이후 다른 영업파트너가 등록할 수 있습니다.</li>
|
||||
<li>만료 후 <strong>1개월간</strong> 재등록 대기 기간이 적용됩니다.</li>
|
||||
<li>대기 기간 이후 다른 영업파트너가 등록할 수 있습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -241,14 +213,31 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fileInput = document.getElementById('ocr-file-input');
|
||||
const cameraInput = document.getElementById('ocr-camera-input');
|
||||
const cameraBtn = document.getElementById('ocr-camera-btn');
|
||||
const dropZone = document.getElementById('ocr-drop-zone');
|
||||
const previewImage = document.getElementById('ocr-preview-image');
|
||||
const statusEl = document.getElementById('ocr-status');
|
||||
const businessCardFile = document.getElementById('business_card_file');
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
// 드래그 앤 드롭
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
// 카메라 버튼 클릭 시 카메라 입력 트리거
|
||||
cameraBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // dropZone 클릭 이벤트 전파 방지
|
||||
cameraInput.click();
|
||||
});
|
||||
|
||||
// 카메라 입력 처리
|
||||
cameraInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length) handleFile(e.target.files[0]);
|
||||
});
|
||||
|
||||
// 드래그 앤 드롭 (드롭존 클릭 시 파일 선택)
|
||||
dropZone.addEventListener('click', (e) => {
|
||||
// 카메라 버튼 클릭이 아닌 경우에만 파일 선택
|
||||
if (e.target !== cameraBtn && !cameraBtn.contains(e.target)) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
@@ -279,11 +268,6 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
previewImage.src = base64;
|
||||
previewImage.style.display = 'block';
|
||||
|
||||
// DataTransfer를 사용하여 명함 이미지 필드에도 파일 설정
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
businessCardFile.files = dataTransfer.files;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("api.business-card-ocr") }}', {
|
||||
method: 'POST',
|
||||
@@ -413,66 +397,6 @@ function showStatus(type, text) {
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
});
|
||||
});
|
||||
|
||||
// 신분증 업로드 처리
|
||||
const idCardDropZone = document.getElementById('id-card-drop-zone');
|
||||
const idCardFile = document.getElementById('id_card_file');
|
||||
const idCardPreview = document.getElementById('id-card-preview');
|
||||
|
||||
idCardDropZone.addEventListener('click', () => idCardFile.click());
|
||||
idCardDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
idCardDropZone.classList.add('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
idCardDropZone.addEventListener('dragleave', () => {
|
||||
idCardDropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
idCardDropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
idCardDropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
if (e.dataTransfer.files.length) handleIdCardFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
idCardFile.addEventListener('change', (e) => {
|
||||
if (e.target.files.length) handleIdCardFile(e.target.files[0]);
|
||||
});
|
||||
|
||||
async function handleIdCardFile(file) {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
idCardPreview.src = base64;
|
||||
idCardPreview.classList.remove('hidden');
|
||||
document.getElementById('id_card_image_data').value = base64;
|
||||
}
|
||||
|
||||
// 통장사본 업로드 처리
|
||||
const bankbookDropZone = document.getElementById('bankbook-drop-zone');
|
||||
const bankbookFile = document.getElementById('bankbook_file');
|
||||
const bankbookPreview = document.getElementById('bankbook-preview');
|
||||
|
||||
bankbookDropZone.addEventListener('click', () => bankbookFile.click());
|
||||
bankbookDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
bankbookDropZone.classList.add('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
bankbookDropZone.addEventListener('dragleave', () => {
|
||||
bankbookDropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
bankbookDropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
bankbookDropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
if (e.dataTransfer.files.length) handleBankbookFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
bankbookFile.addEventListener('change', (e) => {
|
||||
if (e.target.files.length) handleBankbookFile(e.target.files[0]);
|
||||
});
|
||||
|
||||
async function handleBankbookFile(file) {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const base64 = await fileToBase64(file);
|
||||
bankbookPreview.src = base64;
|
||||
bankbookPreview.classList.remove('hidden');
|
||||
document.getElementById('bankbook_image_data').value = base64;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -95,55 +95,6 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
<p class="mt-1 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
|
||||
</div>
|
||||
|
||||
<!-- 추가 첨부파일 -->
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">추가 서류</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">신분증 사본</label>
|
||||
@if($prospect->hasIdCard())
|
||||
<div class="mb-2 p-2 bg-gray-50 rounded-lg" id="id_card_preview">
|
||||
<div class="flex items-start gap-3">
|
||||
<img src="{{ $prospect->id_card_url }}" alt="현재 신분증" class="max-h-32 rounded">
|
||||
<button type="button" onclick="deleteAttachment('id_card')"
|
||||
class="text-red-500 hover:text-red-700 text-sm flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">새 이미지를 업로드하면 기존 이미지가 교체됩니다</p>
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="id_card" accept="image/*"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">통장 사본</label>
|
||||
@if($prospect->hasBankbook())
|
||||
<div class="mb-2 p-2 bg-gray-50 rounded-lg" id="bankbook_preview">
|
||||
<div class="flex items-start gap-3">
|
||||
<img src="{{ $prospect->bankbook_url }}" alt="현재 통장사본" class="max-h-32 rounded">
|
||||
<button type="button" onclick="deleteAttachment('bankbook')"
|
||||
class="text-red-500 hover:text-red-700 text-sm flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">새 이미지를 업로드하면 기존 이미지가 교체됩니다</p>
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="bankbook" accept="image/*"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">메모</label>
|
||||
<textarea name="memo" rows="3"
|
||||
|
||||
@@ -103,25 +103,25 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
@if($prospect->isConverted())
|
||||
<div class="text-green-600">{{ $prospect->converted_at?->format('Y-m-d') }} 전환</div>
|
||||
<div class="text-green-600">{{ $prospect->converted_at?->format('Y-m-d') }} 계약</div>
|
||||
@elseif($prospect->isActive())
|
||||
<div class="text-blue-600">{{ $prospect->expires_at->format('Y-m-d') }} 까지</div>
|
||||
@else
|
||||
<div class="text-gray-500">{{ $prospect->expires_at->format('Y-m-d') }} 만료</div>
|
||||
@if($prospect->isInCooldown())
|
||||
<div class="text-xs text-yellow-600">쿨다운: {{ $prospect->cooldown_ends_at->format('Y-m-d') }}</div>
|
||||
<div class="text-xs text-yellow-600">대기: {{ $prospect->cooldown_ends_at->format('Y-m-d') }}</div>
|
||||
@endif
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('sales.prospects.show', $prospect->id) }}" class="text-blue-600 hover:text-blue-900 mr-3">상세</a>
|
||||
<button type="button" onclick="openProspectShowModal({{ $prospect->id }})" class="text-blue-600 hover:text-blue-900 mr-3">상세</button>
|
||||
@if(!$prospect->isConverted())
|
||||
<a href="{{ route('sales.prospects.edit', $prospect->id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">수정</a>
|
||||
<button type="button" onclick="openProspectEditModal({{ $prospect->id }})" class="text-indigo-600 hover:text-indigo-900 mr-3">수정</button>
|
||||
@if($prospect->isActive())
|
||||
<form action="{{ route('sales.prospects.convert', $prospect->id) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('테넌트로 전환하시겠습니까?')">
|
||||
onsubmit="return confirm('계약 처리하시겠습니까?')">
|
||||
@csrf
|
||||
<button type="submit" class="text-green-600 hover:text-green-900 mr-3">전환</button>
|
||||
<button type="submit" class="text-green-600 hover:text-green-900 mr-3">계약</button>
|
||||
</form>
|
||||
@endif
|
||||
<form action="{{ route('sales.prospects.destroy', $prospect->id) }}" method="POST" class="inline"
|
||||
@@ -152,4 +152,109 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 고객 모달 -->
|
||||
<div id="prospectModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
|
||||
<!-- 모달 컨텐츠 wrapper -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="prospectModalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-3xl">
|
||||
<!-- AJAX로 로드되는 내용 -->
|
||||
<div class="flex items-center justify-center p-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 전역 함수 등록
|
||||
window.openProspectShowModal = function(id) {
|
||||
const modal = document.getElementById('prospectModal');
|
||||
const content = document.getElementById('prospectModalContent');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 로딩 표시
|
||||
content.innerHTML = `
|
||||
<div class="flex items-center justify-center p-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// AJAX로 내용 로드
|
||||
fetch(`/sales/prospects/${id}/modal-show`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
content.innerHTML = '<div class="p-6 text-center text-red-500">내용을 불러올 수 없습니다.</div>';
|
||||
});
|
||||
};
|
||||
|
||||
window.openProspectEditModal = function(id) {
|
||||
const modal = document.getElementById('prospectModal');
|
||||
const content = document.getElementById('prospectModalContent');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 로딩 표시
|
||||
content.innerHTML = `
|
||||
<div class="flex items-center justify-center p-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// AJAX로 내용 로드
|
||||
fetch(`/sales/prospects/${id}/modal-edit`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
content.innerHTML = '<div class="p-6 text-center text-red-500">내용을 불러올 수 없습니다.</div>';
|
||||
});
|
||||
};
|
||||
|
||||
window.closeProspectModal = function() {
|
||||
document.getElementById('prospectModal').classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('prospectModal');
|
||||
if (!modal.classList.contains('hidden')) {
|
||||
window.closeProspectModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 이벤트 델리게이션 (닫기 버튼)
|
||||
document.addEventListener('click', function(e) {
|
||||
const closeBtn = e.target.closest('[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
window.closeProspectModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
109
resources/views/sales/prospects/partials/edit-modal.blade.php
Normal file
109
resources/views/sales/prospects/partials/edit-modal.blade.php
Normal file
@@ -0,0 +1,109 @@
|
||||
{{-- 고객 수정 모달 내용 --}}
|
||||
<div class="p-6 max-h-[80vh] overflow-y-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">고객 정보 수정</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $prospect->company_name }} ({{ $prospect->business_number }})</p>
|
||||
</div>
|
||||
<button type="button" data-close-modal class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
|
||||
<!-- 폼 -->
|
||||
<form action="{{ route('sales.prospects.update', $prospect->id) }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- 사업자번호 (수정 불가) -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">사업자번호</label>
|
||||
<input type="text" value="{{ $prospect->business_number }}" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">회사명 <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="company_name" value="{{ $prospect->company_name }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">대표자명</label>
|
||||
<input type="text" name="ceo_name" value="{{ $prospect->ceo_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">연락처</label>
|
||||
<input type="text" name="contact_phone" value="{{ $prospect->contact_phone }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" name="contact_email" value="{{ $prospect->contact_email }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">주소</label>
|
||||
<input type="text" name="address" value="{{ $prospect->address }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">명함 이미지</label>
|
||||
@if($prospect->hasBusinessCard())
|
||||
<div class="mb-2 p-2 bg-gray-50 rounded-lg flex items-center gap-3" id="prospect_card_preview">
|
||||
<img src="{{ $prospect->business_card_url }}" alt="현재 명함" class="h-16 rounded">
|
||||
<span class="text-xs text-gray-500">새 이미지 업로드 시 교체됨</span>
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="business_card" accept="image/*"
|
||||
class="w-full text-sm text-gray-500 file:mr-2 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">메모</label>
|
||||
<textarea name="memo" rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">{{ $prospect->memo }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- 영업권 상태 정보 -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 mb-4">
|
||||
<h3 class="text-xs font-medium text-gray-800 mb-2">영업권 상태</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="text-gray-500">상태</div>
|
||||
<div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $prospect->status_color }}">
|
||||
{{ $prospect->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-gray-500">등록일</div>
|
||||
<div class="font-medium">{{ $prospect->registered_at->format('Y-m-d') }}</div>
|
||||
<div class="text-gray-500">만료일</div>
|
||||
<div class="font-medium">{{ $prospect->expires_at->format('Y-m-d') }}</div>
|
||||
<div class="text-gray-500">등록자</div>
|
||||
<div class="font-medium">{{ $prospect->registeredBy?->name ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 버튼 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" data-close-modal
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition text-sm">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-sm">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
150
resources/views/sales/prospects/partials/show-modal.blade.php
Normal file
150
resources/views/sales/prospects/partials/show-modal.blade.php
Normal file
@@ -0,0 +1,150 @@
|
||||
{{-- 고객 상세 모달 내용 --}}
|
||||
<div class="p-6 max-h-[80vh] overflow-y-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">{{ $prospect->company_name }}</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full {{ $prospect->status_color }}">
|
||||
{{ $prospect->status_label }}
|
||||
</span>
|
||||
<span class="ml-2">{{ $prospect->business_number }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" data-close-modal class="text-gray-400 hover:text-gray-600">
|
||||
<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="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- 회사 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">회사 정보</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">사업자번호</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->business_number }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">회사명</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->company_name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">대표자</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->ceo_name ?? '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">연락처</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->contact_phone ?? '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">이메일</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->contact_email ?? '-' }}</dd>
|
||||
</div>
|
||||
@if($prospect->address)
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">주소</dt>
|
||||
<dd class="font-medium text-gray-900 text-right text-xs">{{ $prospect->address }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- 영업권 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">영업권 정보</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">등록자</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->registeredBy?->name ?? '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">등록일</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->registered_at->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">만료일</dt>
|
||||
<dd class="font-medium {{ $prospect->isActive() ? 'text-blue-600' : 'text-gray-500' }}">
|
||||
{{ $prospect->expires_at->format('Y-m-d') }}
|
||||
@if($prospect->isActive())
|
||||
<span class="text-xs">(D-{{ $prospect->remaining_days }})</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@if($prospect->isConverted())
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">계약일</dt>
|
||||
<dd class="font-medium text-green-600">{{ $prospect->converted_at?->format('Y-m-d') }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">계약 처리자</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->convertedBy?->name ?? '-' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 첨부 이미지 -->
|
||||
@if($prospect->hasBusinessCard())
|
||||
<div class="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-3">명함 이미지</h3>
|
||||
<a href="{{ $prospect->business_card_url }}" target="_blank" class="block">
|
||||
<img src="{{ $prospect->business_card_url }}" alt="명함 이미지" class="max-h-32 rounded-lg shadow hover:shadow-lg transition">
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 메모 -->
|
||||
@if($prospect->memo)
|
||||
<div class="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2">메모</h3>
|
||||
<p class="text-sm text-gray-700 whitespace-pre-line">{{ $prospect->memo }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 상태별 안내 -->
|
||||
<div class="mt-4">
|
||||
@if($prospect->isActive())
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>영업권 유효:</strong> {{ $prospect->expires_at->format('Y-m-d') }}까지 (D-{{ $prospect->remaining_days }})
|
||||
</p>
|
||||
</div>
|
||||
@elseif($prospect->isConverted())
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p class="text-sm text-green-700">
|
||||
<strong>계약 완료:</strong> {{ $prospect->converted_at?->format('Y-m-d') }}에 계약되었습니다.
|
||||
</p>
|
||||
</div>
|
||||
@elseif($prospect->isInCooldown())
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>재등록 대기:</strong> {{ $prospect->cooldown_ends_at->format('Y-m-d') }} 이후 재등록 가능
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<p class="text-sm text-gray-700">
|
||||
<strong>영업권 만료:</strong> 재등록이 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 푸터 버튼 -->
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" data-close-modal
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition text-sm">
|
||||
닫기
|
||||
</button>
|
||||
@if(!$prospect->isConverted())
|
||||
<button type="button" onclick="window.openProspectEditModal({{ $prospect->id }})"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition text-sm">
|
||||
수정
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">쿨다운 종료일</dt>
|
||||
<dt class="text-gray-500">대기 종료일</dt>
|
||||
<dd class="font-medium text-gray-900">{{ $prospect->cooldown_ends_at->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@if($prospect->isConverted())
|
||||
@@ -137,39 +137,17 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
|
||||
</div>
|
||||
|
||||
<!-- 첨부 이미지 -->
|
||||
@if($prospect->hasBusinessCard() || $prospect->hasIdCard() || $prospect->hasBankbook())
|
||||
@if($prospect->hasBusinessCard())
|
||||
<div class="mt-6 bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">첨부 서류</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- 명함 이미지 -->
|
||||
@if($prospect->hasBusinessCard())
|
||||
<div class="text-center">
|
||||
<h3 class="text-sm font-medium text-gray-600 mb-2">명함</h3>
|
||||
<a href="{{ $prospect->business_card_url }}" target="_blank" class="block">
|
||||
<img src="{{ $prospect->business_card_url }}" alt="명함 이미지" class="max-h-48 mx-auto rounded-lg shadow hover:shadow-lg transition">
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 신분증 이미지 -->
|
||||
@if($prospect->hasIdCard())
|
||||
<div class="text-center">
|
||||
<h3 class="text-sm font-medium text-gray-600 mb-2">신분증 사본</h3>
|
||||
<a href="{{ $prospect->id_card_url }}" target="_blank" class="block">
|
||||
<img src="{{ $prospect->id_card_url }}" alt="신분증 이미지" class="max-h-48 mx-auto rounded-lg shadow hover:shadow-lg transition">
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 통장사본 이미지 -->
|
||||
@if($prospect->hasBankbook())
|
||||
<div class="text-center">
|
||||
<h3 class="text-sm font-medium text-gray-600 mb-2">통장 사본</h3>
|
||||
<a href="{{ $prospect->bankbook_url }}" target="_blank" class="block">
|
||||
<img src="{{ $prospect->bankbook_url }}" alt="통장사본 이미지" class="max-h-48 mx-auto rounded-lg shadow hover:shadow-lg transition">
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -194,7 +172,7 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
|
||||
</div>
|
||||
@elseif($prospect->isInCooldown())
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-yellow-800 mb-2">쿨다운 기간</h3>
|
||||
<h3 class="font-medium text-yellow-800 mb-2">재등록 대기 기간</h3>
|
||||
<p class="text-sm text-yellow-700">
|
||||
영업권이 만료되었습니다.
|
||||
{{ $prospect->cooldown_ends_at->format('Y-m-d') }} 이후 다른 영업파트너가 재등록할 수 있습니다.
|
||||
@@ -211,7 +189,7 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-gray-800 mb-2">영업권 만료</h3>
|
||||
<p class="text-sm text-gray-700">
|
||||
영업권이 만료되었습니다. 쿨다운 기간이 종료되어 재등록이 가능합니다.
|
||||
영업권이 만료되었습니다. 대기 기간이 종료되어 재등록이 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
/* Alpine.js x-cloak: 초기화 전 숨김 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.provider-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -62,14 +65,11 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="space-y-6" x-data="{ activeTab: 'ai' }">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">AI 설정 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">AI API 키 및 모델 설정을 관리합니다</p>
|
||||
</div>
|
||||
<button type="button" onclick="openModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-gray-800">AI 및 스토리지 설정</h1>
|
||||
<button type="button" @click="activeTab === 'ai' ? openModal() : openGcsModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
|
||||
<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 4v16m8-8H4" />
|
||||
</svg>
|
||||
@@ -77,70 +77,242 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 설정 목록 -->
|
||||
<div id="config-list">
|
||||
@forelse($configs as $config)
|
||||
<div class="config-card" data-id="{{ $config->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="font-semibold text-lg text-gray-800">{{ $config->name }}</h3>
|
||||
<span class="provider-badge provider-{{ $config->provider }}">
|
||||
{{ $config->provider_label }}
|
||||
</span>
|
||||
<span class="status-badge {{ $config->is_active ? 'status-active' : 'status-inactive' }}">
|
||||
{{ $config->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p><span class="font-medium">모델:</span> {{ $config->model }}</p>
|
||||
<p><span class="font-medium">인증:</span> {{ $config->auth_type_label }}</p>
|
||||
@if($config->isVertexAi())
|
||||
<p><span class="font-medium">프로젝트:</span> {{ $config->getProjectId() }} ({{ $config->getRegion() }})</p>
|
||||
@else
|
||||
<p><span class="font-medium">API 키:</span> {{ $config->masked_api_key }}</p>
|
||||
@endif
|
||||
@if($config->description)
|
||||
<p><span class="font-medium">설명:</span> {{ $config->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="testConnection({{ $config->id }})" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
테스트
|
||||
</button>
|
||||
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
|
||||
{{ $config->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button type="button" data-config='@json($config)' onclick="editConfig(this)" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button type="button"
|
||||
@click="activeTab = 'ai'"
|
||||
:class="activeTab === 'ai' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
AI 설정
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500">등록된 AI 설정이 없습니다.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">'새 설정 추가' 버튼을 클릭하여 AI API를 등록하세요.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="activeTab = 'storage'"
|
||||
:class="activeTab === 'storage' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
스토리지 설정 (GCS)
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용 안내 -->
|
||||
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-blue-800 mb-2">사용 안내</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>각 Provider(Gemini, Claude, OpenAI)별로 하나의 설정만 활성화할 수 있습니다.</li>
|
||||
<li>명함 OCR 기능은 Gemini Vision API를 사용합니다.</li>
|
||||
<li>API 키는 각 제공자의 콘솔에서 발급받을 수 있습니다.</li>
|
||||
<li>테스트 버튼으로 API 연결 상태를 확인할 수 있습니다.</li>
|
||||
</ul>
|
||||
<!-- AI 설정 탭 콘텐츠 -->
|
||||
<div x-show="activeTab === 'ai'" x-cloak>
|
||||
<!-- AI 설정 목록 -->
|
||||
<div id="config-list" class="space-y-4">
|
||||
@forelse($configs as $config)
|
||||
<div class="config-card" data-id="{{ $config->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="font-semibold text-lg text-gray-800">{{ $config->name }}</h3>
|
||||
<span class="provider-badge provider-{{ $config->provider }}">
|
||||
{{ $config->provider_label }}
|
||||
</span>
|
||||
<span class="status-badge {{ $config->is_active ? 'status-active' : 'status-inactive' }}">
|
||||
{{ $config->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p><span class="font-medium">모델:</span> {{ $config->model }}</p>
|
||||
<p><span class="font-medium">인증:</span> {{ $config->auth_type_label }}</p>
|
||||
@if($config->isVertexAi())
|
||||
<p><span class="font-medium">프로젝트:</span> {{ $config->getProjectId() }} ({{ $config->getRegion() }})</p>
|
||||
@else
|
||||
<p><span class="font-medium">API 키:</span> {{ $config->masked_api_key }}</p>
|
||||
@endif
|
||||
@if($config->description)
|
||||
<p><span class="font-medium">설명:</span> {{ $config->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="testConnection({{ $config->id }})" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
테스트
|
||||
</button>
|
||||
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
|
||||
{{ $config->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button type="button" data-config='@json($config)' onclick="editConfig(this)" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500">등록된 AI 설정이 없습니다.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">'새 설정 추가' 버튼을 클릭하여 AI API를 등록하세요.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- AI 사용 안내 -->
|
||||
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-blue-800 mb-2">사용 안내</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>각 Provider(Gemini, Claude, OpenAI)별로 하나의 설정만 활성화할 수 있습니다.</li>
|
||||
<li>명함 OCR 기능은 Gemini Vision API를 사용합니다.</li>
|
||||
<li>API 키는 각 제공자의 콘솔에서 발급받을 수 있습니다.</li>
|
||||
<li>테스트 버튼으로 API 연결 상태를 확인할 수 있습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스토리지 설정 (GCS) 탭 콘텐츠 -->
|
||||
<div x-show="activeTab === 'storage'" x-cloak>
|
||||
<!-- GCS 설정 목록 -->
|
||||
<div id="storage-config-list" class="space-y-4">
|
||||
@forelse($storageConfigs as $config)
|
||||
<div class="config-card" data-id="{{ $config->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="font-semibold text-lg text-gray-800">{{ $config->name }}</h3>
|
||||
<span class="provider-badge" style="background: #e8f5e9; color: #2e7d32;">
|
||||
Google Cloud Storage
|
||||
</span>
|
||||
<span class="status-badge {{ $config->is_active ? 'status-active' : 'status-inactive' }}">
|
||||
{{ $config->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p><span class="font-medium">버킷:</span> {{ $config->getBucketName() ?? '-' }}</p>
|
||||
<p><span class="font-medium">서비스 계정:</span>
|
||||
@if($config->getServiceAccountPath())
|
||||
파일 경로: {{ $config->getServiceAccountPath() }}
|
||||
@elseif($config->getServiceAccountJson())
|
||||
JSON 직접 입력됨
|
||||
@else
|
||||
미설정
|
||||
@endif
|
||||
</p>
|
||||
@if($config->description)
|
||||
<p><span class="font-medium">설명:</span> {{ $config->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="testGcsConnection({{ $config->id }})" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
테스트
|
||||
</button>
|
||||
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
|
||||
{{ $config->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button type="button" data-config='@json($config)' onclick="editGcsConfig(this)" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<p class="text-gray-500">등록된 GCS 설정이 없습니다.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">'새 설정 추가' 버튼을 클릭하여 Google Cloud Storage를 등록하세요.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- GCS 사용 안내 -->
|
||||
<div class="mt-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-green-800 mb-2">Google Cloud Storage 사용 안내</h3>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>음성 녹음 파일(10MB 이상)은 GCS에 자동 백업됩니다.</li>
|
||||
<li>GCP 콘솔에서 서비스 계정을 생성하고 Storage 권한을 부여하세요.</li>
|
||||
<li>서비스 계정 키(JSON)를 직접 입력하거나, 파일 경로를 지정할 수 있습니다.</li>
|
||||
<li>버킷은 미리 GCP 콘솔에서 생성해 두어야 합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GCS 추가/수정 모달 -->
|
||||
<div id="gcs-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="gcs-modal-title" class="text-xl font-bold text-gray-800">GCS 설정 추가</h2>
|
||||
<button type="button" onclick="closeGcsModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
|
||||
<form id="gcs-form" class="space-y-4">
|
||||
<input type="hidden" id="gcs-config-id" value="">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설정 이름 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="gcs-name" required placeholder="예: Production GCS" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">버킷 이름 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="gcs-bucket-name" required placeholder="예: my-bucket-name" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">GCP 콘솔에서 생성한 버킷 이름</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">인증 방식</label>
|
||||
<select id="gcs-auth-type" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="path">파일 경로</option>
|
||||
<option value="json">JSON 직접 입력</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="gcs-path-section">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">서비스 계정 파일 경로</label>
|
||||
<input type="text" id="gcs-service-account-path" placeholder="/var/www/sales/apikey/google_service_account.json" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">Docker 컨테이너 내부 경로 (기본: /var/www/sales/apikey/google_service_account.json)</p>
|
||||
</div>
|
||||
|
||||
<div id="gcs-json-section" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">서비스 계정 JSON</label>
|
||||
<textarea id="gcs-service-account-json" rows="6" placeholder='{"type": "service_account", "project_id": "...", ...}' class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">GCP에서 다운로드한 JSON 키 내용을 붙여넣기</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명 (선택)</label>
|
||||
<textarea id="gcs-description" rows="2" placeholder="설정에 대한 설명" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="gcs-is-active" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<label for="gcs-is-active" class="ml-2 text-sm text-gray-700">활성화 (기존 GCS 설정은 비활성화됩니다)</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onclick="closeGcsModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="testGcsConnectionFromModal()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
|
||||
연결 테스트
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -430,16 +602,38 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
|
||||
// 연결 테스트 (모달에서)
|
||||
window.testConnectionFromModal = async function() {
|
||||
const provider = document.getElementById('config-provider').value;
|
||||
const authType = document.getElementById('config-auth-type').value;
|
||||
|
||||
const data = {
|
||||
provider: document.getElementById('config-provider').value,
|
||||
api_key: document.getElementById('config-api-key').value,
|
||||
provider: provider,
|
||||
model: document.getElementById('config-model').value,
|
||||
base_url: document.getElementById('config-base-url').value || null
|
||||
auth_type: authType
|
||||
};
|
||||
|
||||
if (!data.api_key) {
|
||||
showToast('API 키를 입력해주세요.', 'error');
|
||||
return;
|
||||
// Vertex AI 방식인 경우
|
||||
if (provider === 'gemini' && authType === 'vertex_ai') {
|
||||
data.project_id = document.getElementById('config-project-id').value;
|
||||
data.region = document.getElementById('config-region').value;
|
||||
data.service_account_path = document.getElementById('config-service-account-path').value;
|
||||
|
||||
if (!data.project_id) {
|
||||
showToast('프로젝트 ID를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!data.service_account_path) {
|
||||
showToast('서비스 계정 파일 경로를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// API 키 방식인 경우
|
||||
data.api_key = document.getElementById('config-api-key').value;
|
||||
data.base_url = document.getElementById('config-base-url').value || null;
|
||||
|
||||
if (!data.api_key) {
|
||||
showToast('API 키를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showToast('연결 테스트 중...', 'info');
|
||||
@@ -457,7 +651,7 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast('연결 테스트 성공!', 'success');
|
||||
showToast(result.message || '연결 테스트 성공!', 'success');
|
||||
} else {
|
||||
showToast(result.error || '연결 테스트 실패', 'error');
|
||||
}
|
||||
@@ -539,6 +733,189 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
}
|
||||
}
|
||||
|
||||
// === GCS 설정 관련 함수들 ===
|
||||
|
||||
// GCS 모달 열기
|
||||
window.openGcsModal = function(config) {
|
||||
const modal = document.getElementById('gcs-modal');
|
||||
const title = document.getElementById('gcs-modal-title');
|
||||
|
||||
if (config) {
|
||||
title.textContent = 'GCS 설정 수정';
|
||||
document.getElementById('gcs-config-id').value = config.id;
|
||||
document.getElementById('gcs-name').value = config.name;
|
||||
document.getElementById('gcs-description').value = config.description || '';
|
||||
document.getElementById('gcs-is-active').checked = config.is_active;
|
||||
|
||||
const options = config.options || {};
|
||||
document.getElementById('gcs-bucket-name').value = options.bucket_name || '';
|
||||
document.getElementById('gcs-service-account-path').value = options.service_account_path || '';
|
||||
|
||||
if (options.service_account_json) {
|
||||
document.getElementById('gcs-auth-type').value = 'json';
|
||||
document.getElementById('gcs-service-account-json').value = JSON.stringify(options.service_account_json, null, 2);
|
||||
toggleGcsAuthType('json');
|
||||
} else {
|
||||
document.getElementById('gcs-auth-type').value = 'path';
|
||||
toggleGcsAuthType('path');
|
||||
}
|
||||
} else {
|
||||
title.textContent = 'GCS 설정 추가';
|
||||
document.getElementById('gcs-form').reset();
|
||||
document.getElementById('gcs-config-id').value = '';
|
||||
document.getElementById('gcs-service-account-path').value = '/var/www/sales/apikey/google_service_account.json';
|
||||
toggleGcsAuthType('path');
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
// GCS 모달 닫기
|
||||
window.closeGcsModal = function() {
|
||||
document.getElementById('gcs-modal').classList.add('hidden');
|
||||
};
|
||||
|
||||
// GCS 인증 방식 전환
|
||||
function toggleGcsAuthType(type) {
|
||||
const pathSection = document.getElementById('gcs-path-section');
|
||||
const jsonSection = document.getElementById('gcs-json-section');
|
||||
|
||||
if (type === 'json') {
|
||||
pathSection.classList.add('hidden');
|
||||
jsonSection.classList.remove('hidden');
|
||||
} else {
|
||||
pathSection.classList.remove('hidden');
|
||||
jsonSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// GCS 수정
|
||||
window.editGcsConfig = function(btn) {
|
||||
try {
|
||||
const config = JSON.parse(btn.dataset.config);
|
||||
window.openGcsModal(config);
|
||||
} catch (e) {
|
||||
console.error('Config parse error:', e);
|
||||
showToast('설정 데이터를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// GCS 연결 테스트 (목록)
|
||||
window.testGcsConnection = function(id) {
|
||||
showToast('GCS 수정 화면에서 테스트해주세요.', 'warning');
|
||||
};
|
||||
|
||||
// GCS 연결 테스트 (모달)
|
||||
window.testGcsConnectionFromModal = async function() {
|
||||
const authType = document.getElementById('gcs-auth-type').value;
|
||||
const data = {
|
||||
bucket_name: document.getElementById('gcs-bucket-name').value,
|
||||
};
|
||||
|
||||
if (authType === 'json') {
|
||||
try {
|
||||
const jsonText = document.getElementById('gcs-service-account-json').value;
|
||||
data.service_account_json = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
showToast('JSON 형식이 올바르지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
data.service_account_path = document.getElementById('gcs-service-account-path').value;
|
||||
}
|
||||
|
||||
if (!data.bucket_name) {
|
||||
showToast('버킷 이름을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('GCS 연결 테스트 중...', 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("system.ai-config.test-gcs") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.error || '연결 테스트 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('테스트 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// GCS 폼 제출
|
||||
async function handleGcsFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = document.getElementById('gcs-config-id').value;
|
||||
const authType = document.getElementById('gcs-auth-type').value;
|
||||
|
||||
const data = {
|
||||
provider: 'gcs',
|
||||
name: document.getElementById('gcs-name').value,
|
||||
description: document.getElementById('gcs-description').value || null,
|
||||
is_active: document.getElementById('gcs-is-active').checked,
|
||||
options: {
|
||||
bucket_name: document.getElementById('gcs-bucket-name').value,
|
||||
}
|
||||
};
|
||||
|
||||
if (authType === 'json') {
|
||||
try {
|
||||
const jsonText = document.getElementById('gcs-service-account-json').value;
|
||||
data.options.service_account_json = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
showToast('JSON 형식이 올바르지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
data.options.service_account_path = document.getElementById('gcs-service-account-path').value;
|
||||
}
|
||||
|
||||
if (!data.options.bucket_name) {
|
||||
showToast('버킷 이름을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id
|
||||
? `{{ url('system/ai-config') }}/${id}`
|
||||
: '{{ route("system.ai-config.store") }}';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
window.closeGcsModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.message || '저장 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// DOM 로드 후 이벤트 리스너 등록
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 페이지 로드 시 모달 강제 닫기
|
||||
@@ -546,6 +923,10 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
const gcsModal = document.getElementById('gcs-modal');
|
||||
if (gcsModal) {
|
||||
gcsModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Provider 변경 시 기본 모델 업데이트 및 UI 전환
|
||||
const providerEl = document.getElementById('config-provider');
|
||||
@@ -578,6 +959,20 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
formEl.addEventListener('submit', handleFormSubmit);
|
||||
}
|
||||
|
||||
// GCS 폼 제출
|
||||
const gcsFormEl = document.getElementById('gcs-form');
|
||||
if (gcsFormEl) {
|
||||
gcsFormEl.addEventListener('submit', handleGcsFormSubmit);
|
||||
}
|
||||
|
||||
// GCS 인증 방식 변경
|
||||
const gcsAuthTypeEl = document.getElementById('gcs-auth-type');
|
||||
if (gcsAuthTypeEl) {
|
||||
gcsAuthTypeEl.addEventListener('change', function() {
|
||||
toggleGcsAuthType(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫지 않음 (의도치 않은 닫힘 방지)
|
||||
// 닫기 버튼이나 취소 버튼으로만 닫을 수 있음
|
||||
});
|
||||
|
||||
105
routes/web.php
105
routes/web.php
@@ -18,6 +18,7 @@
|
||||
use App\Http\Controllers\FcmController;
|
||||
use App\Http\Controllers\ItemFieldController;
|
||||
use App\Http\Controllers\Lab\AIController;
|
||||
use App\Http\Controllers\Lab\ManagementController;
|
||||
use App\Http\Controllers\Lab\StrategyController;
|
||||
use App\Http\Controllers\MenuController;
|
||||
use App\Http\Controllers\MenuSyncController;
|
||||
@@ -30,6 +31,7 @@
|
||||
use App\Http\Controllers\QuoteFormulaController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
use App\Http\Controllers\Sales\SalesProductController;
|
||||
use App\Http\Controllers\System\AiConfigController;
|
||||
use App\Http\Controllers\TenantController;
|
||||
use App\Http\Controllers\TenantSettingController;
|
||||
@@ -356,6 +358,7 @@
|
||||
Route::delete('/{id}', [AiConfigController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/toggle', [AiConfigController::class, 'toggle'])->name('toggle');
|
||||
Route::post('/test', [AiConfigController::class, 'test'])->name('test');
|
||||
Route::post('/test-gcs', [AiConfigController::class, 'testGcs'])->name('test-gcs');
|
||||
});
|
||||
|
||||
// 명함 OCR API
|
||||
@@ -458,6 +461,9 @@
|
||||
Route::get('/inquiry/{inquiryKey}/report', [\App\Http\Controllers\Credit\CreditController::class, 'getReportData'])->name('inquiry.report');
|
||||
Route::delete('/inquiry/{id}', [\App\Http\Controllers\Credit\CreditController::class, 'deleteInquiry'])->name('inquiry.destroy');
|
||||
|
||||
// 조회회수 집계
|
||||
Route::get('/usage', [\App\Http\Controllers\Credit\CreditUsageController::class, 'index'])->name('usage.index');
|
||||
|
||||
// 설정 관리
|
||||
Route::get('/settings', [\App\Http\Controllers\Credit\CreditController::class, 'settings'])->name('settings.index');
|
||||
Route::get('/settings/create', [\App\Http\Controllers\Credit\CreditController::class, 'createConfig'])->name('settings.create');
|
||||
@@ -486,10 +492,7 @@
|
||||
Route::prefix('lab')->name('lab.')->group(function () {
|
||||
// S. 전략 (Strategy)
|
||||
Route::prefix('strategy')->name('strategy.')->group(function () {
|
||||
Route::get('/tax', [StrategyController::class, 'tax'])->name('tax');
|
||||
Route::get('/labor', [StrategyController::class, 'labor'])->name('labor');
|
||||
Route::get('/debt', [StrategyController::class, 'debt'])->name('debt');
|
||||
Route::get('/mrp-overseas', [StrategyController::class, 'mrpOverseas'])->name('mrp-overseas');
|
||||
Route::get('/chatbot', [StrategyController::class, 'chatbot'])->name('chatbot');
|
||||
Route::get('/knowledge-search', [StrategyController::class, 'knowledgeSearch'])->name('knowledge-search');
|
||||
Route::get('/chatbot-compare', [StrategyController::class, 'chatbotCompare'])->name('chatbot-compare');
|
||||
@@ -498,17 +501,6 @@
|
||||
Route::get('/confluence-vs-notion', [StrategyController::class, 'confluenceVsNotion'])->name('confluence-vs-notion');
|
||||
Route::get('/sales-strategy', [StrategyController::class, 'salesStrategy'])->name('sales-strategy');
|
||||
});
|
||||
|
||||
// A. AI/자동화 (AI/Automation)
|
||||
Route::prefix('ai')->name('ai.')->group(function () {
|
||||
Route::get('/web-recording', [AIController::class, 'webRecording'])->name('web-recording');
|
||||
Route::get('/meeting-summary', [AIController::class, 'meetingSummary'])->name('meeting-summary');
|
||||
Route::get('/work-memo-summary', [AIController::class, 'workMemoSummary'])->name('work-memo-summary');
|
||||
Route::get('/operator-chatbot', [AIController::class, 'operatorChatbot'])->name('operator-chatbot');
|
||||
Route::get('/vertex-rag', [AIController::class, 'vertexRag'])->name('vertex-rag');
|
||||
Route::get('/tenant-knowledge', [AIController::class, 'tenantKnowledge'])->name('tenant-knowledge');
|
||||
Route::get('/tenant-chatbot', [AIController::class, 'tenantChatbot'])->name('tenant-chatbot');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -738,14 +730,25 @@
|
||||
return view('finance.purchase');
|
||||
})->name('purchase');
|
||||
|
||||
// 정산관리
|
||||
Route::get('/sales-commission', function () {
|
||||
if (request()->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.sales-commission'));
|
||||
}
|
||||
// 영업수수료정산 (실제 구현)
|
||||
Route::prefix('sales-commissions')->name('sales-commissions.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'index'])->name('index');
|
||||
Route::get('/export', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'export'])->name('export');
|
||||
Route::get('/payment-form', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'paymentForm'])->name('payment-form');
|
||||
Route::get('/table', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'table'])->name('table');
|
||||
Route::get('/stats', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'stats'])->name('stats');
|
||||
Route::post('/payment', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'registerPayment'])->name('payment');
|
||||
Route::post('/bulk-approve', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'bulkApprove'])->name('bulk-approve');
|
||||
Route::post('/bulk-mark-paid', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'bulkMarkPaid'])->name('bulk-mark-paid');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'show'])->name('show');
|
||||
Route::get('/{id}/detail', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'detail'])->name('detail');
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/mark-paid', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'markPaid'])->name('mark-paid');
|
||||
Route::post('/{id}/cancel', [\App\Http\Controllers\Finance\SalesCommissionController::class, 'cancel'])->name('cancel');
|
||||
});
|
||||
|
||||
return view('finance.sales-commission');
|
||||
})->name('sales-commission');
|
||||
// 기존 sales-commission URL 리다이렉트 (호환성)
|
||||
Route::get('/sales-commission', fn() => redirect()->route('finance.sales-commissions.index'))->name('sales-commission');
|
||||
Route::get('/consulting-fee', function () {
|
||||
if (request()->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.consulting-fee'));
|
||||
@@ -842,9 +845,12 @@
|
||||
// 영업관리 대시보드
|
||||
Route::get('salesmanagement/dashboard', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'index'])->name('salesmanagement.dashboard');
|
||||
Route::get('salesmanagement/dashboard/refresh', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'refresh'])->name('salesmanagement.dashboard.refresh');
|
||||
Route::get('salesmanagement/dashboard/tenants', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'refreshTenantList'])->name('salesmanagement.dashboard.tenants');
|
||||
|
||||
// 영업 담당자 관리
|
||||
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');
|
||||
Route::get('managers/{id}/modal-edit', [\App\Http\Controllers\Sales\SalesManagerController::class, 'modalEdit'])->name('managers.modal-edit');
|
||||
Route::post('managers/{id}/approve', [\App\Http\Controllers\Sales\SalesManagerController::class, 'approve'])->name('managers.approve');
|
||||
Route::post('managers/{id}/reject', [\App\Http\Controllers\Sales\SalesManagerController::class, 'reject'])->name('managers.reject');
|
||||
Route::post('managers/{id}/delegate-role', [\App\Http\Controllers\Sales\SalesManagerController::class, 'delegateRole'])->name('managers.delegate-role');
|
||||
@@ -858,7 +864,60 @@
|
||||
Route::post('prospects/{id}/convert', [\App\Http\Controllers\Sales\TenantProspectController::class, 'convert'])->name('prospects.convert');
|
||||
Route::post('prospects/check-business-number', [\App\Http\Controllers\Sales\TenantProspectController::class, 'checkBusinessNumber'])->name('prospects.check-business-number');
|
||||
Route::delete('prospects/{id}/attachment', [\App\Http\Controllers\Sales\TenantProspectController::class, 'deleteAttachment'])->name('prospects.delete-attachment');
|
||||
Route::get('prospects/{id}/modal-show', [\App\Http\Controllers\Sales\TenantProspectController::class, 'modalShow'])->name('prospects.modal-show');
|
||||
Route::get('prospects/{id}/modal-edit', [\App\Http\Controllers\Sales\TenantProspectController::class, 'modalEdit'])->name('prospects.modal-edit');
|
||||
|
||||
// 영업 실적 관리
|
||||
Route::resource('records', \App\Http\Controllers\Sales\SalesRecordController::class);
|
||||
// 영업 시나리오 관리
|
||||
Route::prefix('scenarios')->name('scenarios.')->group(function () {
|
||||
Route::get('/{tenant}/sales', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'salesScenario'])->name('sales');
|
||||
Route::get('/{tenant}/manager', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'managerScenario'])->name('manager');
|
||||
Route::post('/checklist/toggle', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'toggleChecklist'])->name('checklist.toggle');
|
||||
Route::get('/{tenant}/{type}/progress', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'getProgress'])->name('progress');
|
||||
});
|
||||
|
||||
// 상담 기록 관리
|
||||
Route::prefix('consultations')->name('consultations.')->group(function () {
|
||||
Route::get('/{tenant}', [\App\Http\Controllers\Sales\ConsultationController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Sales\ConsultationController::class, 'store'])->name('store');
|
||||
Route::delete('/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio');
|
||||
Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file');
|
||||
Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file');
|
||||
Route::get('/download-audio/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadAudio'])->name('download-audio');
|
||||
Route::get('/download-file/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadFile'])->name('download-file');
|
||||
});
|
||||
|
||||
// 매니저 지정 변경
|
||||
Route::post('/tenants/{tenant}/assign-manager', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'assignManager'])->name('tenants.assign-manager');
|
||||
|
||||
// 매니저 목록 조회 (드롭다운용)
|
||||
Route::get('/managers/list', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'getManagers'])->name('managers.list');
|
||||
|
||||
// 상품관리 (HQ 전용)
|
||||
Route::prefix('products')->name('products.')->group(function () {
|
||||
Route::get('/', [SalesProductController::class, 'index'])->name('index');
|
||||
Route::get('/list', [SalesProductController::class, 'productList'])->name('list');
|
||||
Route::post('/', [SalesProductController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [SalesProductController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [SalesProductController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/toggle', [SalesProductController::class, 'toggleActive'])->name('toggle');
|
||||
Route::post('/reorder', [SalesProductController::class, 'reorder'])->name('reorder');
|
||||
|
||||
// 카테고리 관리
|
||||
Route::prefix('categories')->name('categories.')->group(function () {
|
||||
Route::get('/', [SalesProductController::class, 'categories'])->name('index');
|
||||
Route::post('/', [SalesProductController::class, 'storeCategory'])->name('store');
|
||||
Route::put('/{id}', [SalesProductController::class, 'updateCategory'])->name('update');
|
||||
Route::delete('/{id}', [SalesProductController::class, 'deleteCategory'])->name('destroy');
|
||||
});
|
||||
|
||||
// API (영업 시나리오용)
|
||||
Route::get('/api/list', [SalesProductController::class, 'getProductsApi'])->name('api.list');
|
||||
});
|
||||
|
||||
// 계약관리
|
||||
Route::prefix('contracts')->name('contracts.')->group(function () {
|
||||
Route::post('/products', [\App\Http\Controllers\Sales\SalesContractController::class, 'saveProducts'])->name('products.save');
|
||||
Route::get('/products/{tenant}', [\App\Http\Controllers\Sales\SalesContractController::class, 'getProducts'])->name('products.get');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user