Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-01-30 13:51:49 +09:00
97 changed files with 13237 additions and 4176 deletions

View File

@@ -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

View File

@@ -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 : '',
];
}

View File

@@ -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).',

View File

@@ -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([

View 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;
}
}

View 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);
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
/**
* 상담용 챗봇 전략
*/

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\Tenants\Tenant;
class PermissionController extends Controller
{

View 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);
}
}

View 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,
],
]);
}
}

View File

@@ -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');
}
}

View File

@@ -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'
));
}
/**
* 수정 폼
*/

View 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,
]);
}
}

View 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,
]);
}
}

View File

@@ -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)
*/

View File

@@ -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), '+/', '-_'), '=');
}
}

View File

@@ -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(),

View 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]);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -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
{

View 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');
}
}

View 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);
}
}

View 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');
}
}

View 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'),
];
}
}

View 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,
]);
}
}

View File

@@ -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 '만료';

View File

@@ -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);
}
}

View File

@@ -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);
}
/**
* 상태 라벨
*/

View File

@@ -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
{

View 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;
}
}

View File

@@ -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(),
]
);
}
/**
* 역할 동기화
*/

View File

@@ -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;
}
/**
* 명함 이미지 삭제
*/

View 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,
]);
}
}

View File

@@ -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();
});

View 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/
```

View 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` - 수정 모달 부분 뷰

View 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 |

View 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
View 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
View 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" />',
],
];

View File

@@ -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) {

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View 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

View File

@@ -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>

View 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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 에러 핸들러 (세션 만료 시 자동 갱신 또는 로그인 페이지로 이동)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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">
전체보기 &rarr;
</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>

View File

@@ -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>

View 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(`&quot;${companyName}&quot; 테넌트를 삭제하시겠습니까?\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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View 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>

View 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>

View File

@@ -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

View File

@@ -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);
});
}
// 모달 외부 클릭 시 닫지 않음 (의도치 않은 닫힘 방지)
// 닫기 버튼이나 취소 버튼으로만 닫을 수 있음
});

View File

@@ -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');
});
});