feat:카드 사용내역 메뉴 추가 (바로빌 API 연동)
- EcardController: 카드 목록/사용내역 조회, 계정과목 저장, 엑셀 내보내기 - CardTransaction 모델: 카드 사용내역 저장 및 고유키 매칭 - 마이그레이션: barobill_card_transactions 테이블 생성 - React 기반 UI: 카드 선택, 기간 조회, 계정과목 검색/선택 기능 - MngMenuSeeder 메뉴 URL 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
840
app/Http/Controllers/Barobill/EcardController.php
Normal file
840
app/Http/Controllers/Barobill/EcardController.php
Normal file
@@ -0,0 +1,840 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Barobill;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\AccountCode;
|
||||
use App\Models\Barobill\BarobillConfig;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Models\Barobill\CardTransaction;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 바로빌 카드 사용내역 조회 컨트롤러
|
||||
*/
|
||||
class EcardController extends Controller
|
||||
{
|
||||
/**
|
||||
* 바로빌 SOAP 설정
|
||||
*/
|
||||
private ?string $certKey = null;
|
||||
private ?string $corpNum = null;
|
||||
private bool $isTestMode = false;
|
||||
private ?string $soapUrl = null;
|
||||
private ?\SoapClient $soapClient = null;
|
||||
|
||||
// 바로빌 파트너사 (본사) 테넌트 ID
|
||||
private const HEADQUARTERS_TENANT_ID = 1;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// DB에서 활성화된 바로빌 설정 조회
|
||||
$activeConfig = BarobillConfig::where('is_active', true)->first();
|
||||
|
||||
if ($activeConfig) {
|
||||
$this->certKey = $activeConfig->cert_key;
|
||||
$this->corpNum = $activeConfig->corp_num;
|
||||
$this->isTestMode = $activeConfig->environment === 'test';
|
||||
// 카드 조회는 CARD.asmx 사용
|
||||
$baseUrl = $this->isTestMode
|
||||
? 'https://testws.baroservice.com'
|
||||
: 'https://ws.baroservice.com';
|
||||
$this->soapUrl = $baseUrl . '/CARD.asmx?WSDL';
|
||||
} else {
|
||||
$this->isTestMode = config('services.barobill.test_mode', true);
|
||||
$this->certKey = $this->isTestMode
|
||||
? config('services.barobill.cert_key_test', '')
|
||||
: config('services.barobill.cert_key_prod', '');
|
||||
$this->corpNum = config('services.barobill.corp_num', '');
|
||||
$this->soapUrl = $this->isTestMode
|
||||
? 'https://testws.baroservice.com/CARD.asmx?WSDL'
|
||||
: 'https://ws.baroservice.com/CARD.asmx?WSDL';
|
||||
}
|
||||
|
||||
$this->initSoapClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* SOAP 클라이언트 초기화
|
||||
*/
|
||||
private function initSoapClient(): void
|
||||
{
|
||||
if (!empty($this->certKey) || $this->isTestMode) {
|
||||
try {
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
]
|
||||
]);
|
||||
|
||||
$this->soapClient = new \SoapClient($this->soapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 카드 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 사용내역 메인 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('barobill.ecard.index'));
|
||||
}
|
||||
|
||||
// 현재 선택된 테넌트 정보
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$currentTenant = Tenant::find($tenantId);
|
||||
|
||||
// 해당 테넌트의 바로빌 회원사 정보
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
|
||||
return view('barobill.ecard.index', [
|
||||
'certKey' => $this->certKey,
|
||||
'corpNum' => $this->corpNum,
|
||||
'isTestMode' => $this->isTestMode,
|
||||
'hasSoapClient' => $this->soapClient !== null,
|
||||
'currentTenant' => $currentTenant,
|
||||
'barobillMember' => $barobillMember,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 카드 목록 조회 (GetCardEx)
|
||||
*/
|
||||
public function cards(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$availOnly = $request->input('availOnly', 1);
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
$userId = $barobillMember?->barobill_id ?? '';
|
||||
|
||||
$result = $this->callSoap('GetCardEx', [
|
||||
'AvailOnly' => (int)$availOnly
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
$data = $result['data'];
|
||||
|
||||
// Card 또는 CardEx에서 카드 목록 추출
|
||||
$cardList = [];
|
||||
if (isset($data->Card)) {
|
||||
$cardList = is_array($data->Card) ? $data->Card : [$data->Card];
|
||||
} elseif (isset($data->CardEx)) {
|
||||
$cardList = is_array($data->CardEx) ? $data->CardEx : [$data->CardEx];
|
||||
}
|
||||
|
||||
foreach ($cardList as $card) {
|
||||
if (!is_object($card)) continue;
|
||||
|
||||
$cardNum = $card->CardNum ?? '';
|
||||
if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cards[] = [
|
||||
'cardNum' => $cardNum,
|
||||
'cardCompany' => $card->CardCompany ?? '',
|
||||
'cardBrand' => $this->getCardCompanyName($card->CardCompany ?? ''),
|
||||
'alias' => $card->Alias ?? '',
|
||||
'ownerName' => $card->OwnerName ?? '',
|
||||
'status' => isset($card->UseState) ? (int)$card->UseState : 1
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cards' => $cards,
|
||||
'count' => count($cards)
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('카드 목록 조회 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 사용내역 조회 (GetPeriodCardApprovalLog)
|
||||
*/
|
||||
public function transactions(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$startDate = $request->input('startDate', date('Ymd'));
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$cardNum = str_replace('-', '', $request->input('cardNum', ''));
|
||||
$page = (int)$request->input('page', 1);
|
||||
$limit = (int)$request->input('limit', 50);
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
$userId = $barobillMember?->barobill_id ?? '';
|
||||
|
||||
// DB에서 저장된 계정과목 데이터 조회
|
||||
$savedData = CardTransaction::getByDateRange($tenantId, $startDate, $endDate, $cardNum ?: null);
|
||||
|
||||
// 전체 카드 조회: 빈 값이면 모든 카드의 사용 내역 조회
|
||||
if (empty($cardNum)) {
|
||||
return $this->getAllCardsTransactions($userId, $startDate, $endDate, $page, $limit, $savedData);
|
||||
}
|
||||
|
||||
// 단일 카드 조회
|
||||
$result = $this->callSoap('GetPeriodCardApprovalLog', [
|
||||
'ID' => $userId,
|
||||
'CardNum' => $cardNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page,
|
||||
'OrderDirection' => 2 // 2:내림차순
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
$resultData = $result['data'];
|
||||
|
||||
// 에러 코드 체크
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
if ($errorCode && !in_array($errorCode, [-25005, -25001])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if ($errorCode && in_array($errorCode, [-25005, -25001])) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'summary' => ['totalAmount' => 0, 'count' => 0, 'approvalCount' => 0, 'cancelCount' => 0],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// 데이터 파싱 (저장된 계정과목 병합)
|
||||
$logs = $this->parseTransactionLogs($resultData, $savedData);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $logs['logs'],
|
||||
'pagination' => [
|
||||
'currentPage' => $resultData->CurrentPage ?? 1,
|
||||
'countPerPage' => $resultData->CountPerPage ?? 50,
|
||||
'maxPageNum' => $resultData->MaxPageNum ?? 1,
|
||||
'maxIndex' => $resultData->MaxIndex ?? 0
|
||||
],
|
||||
'summary' => $logs['summary']
|
||||
]
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('카드 사용내역 조회 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카드의 사용 내역 조회
|
||||
*/
|
||||
private function getAllCardsTransactions(string $userId, string $startDate, string $endDate, int $page, int $limit, $savedData = null): JsonResponse
|
||||
{
|
||||
// 먼저 카드 목록 조회
|
||||
$cardResult = $this->callSoap('GetCardEx', ['AvailOnly' => 1]);
|
||||
|
||||
if (!$cardResult['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $cardResult['error']
|
||||
]);
|
||||
}
|
||||
|
||||
$cardList = [];
|
||||
$data = $cardResult['data'];
|
||||
if (isset($data->Card)) {
|
||||
$cardList = is_array($data->Card) ? $data->Card : [$data->Card];
|
||||
} elseif (isset($data->CardEx)) {
|
||||
$cardList = is_array($data->CardEx) ? $data->CardEx : [$data->CardEx];
|
||||
}
|
||||
|
||||
$allLogs = [];
|
||||
$totalAmount = 0;
|
||||
$approvalCount = 0;
|
||||
$cancelCount = 0;
|
||||
|
||||
foreach ($cardList as $card) {
|
||||
if (!is_object($card)) continue;
|
||||
|
||||
$cardNum = $card->CardNum ?? '';
|
||||
if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) continue;
|
||||
|
||||
$cardResult = $this->callSoap('GetPeriodCardApprovalLog', [
|
||||
'ID' => $userId,
|
||||
'CardNum' => $cardNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => 1000,
|
||||
'CurrentPage' => 1,
|
||||
'OrderDirection' => 2
|
||||
]);
|
||||
|
||||
if ($cardResult['success']) {
|
||||
$cardData = $cardResult['data'];
|
||||
$errorCode = $this->checkErrorCode($cardData);
|
||||
|
||||
if (!$errorCode || in_array($errorCode, [-25005, -25001])) {
|
||||
$parsed = $this->parseTransactionLogs($cardData, $savedData);
|
||||
foreach ($parsed['logs'] as $log) {
|
||||
$log['cardBrand'] = $this->getCardCompanyName($card->CardCompany ?? '');
|
||||
$allLogs[] = $log;
|
||||
}
|
||||
$totalAmount += $parsed['summary']['totalAmount'];
|
||||
$approvalCount += $parsed['summary']['approvalCount'];
|
||||
$cancelCount += $parsed['summary']['cancelCount'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜/시간 기준 정렬 (최신순)
|
||||
usort($allLogs, function ($a, $b) {
|
||||
return strcmp($b['useDt'] ?? '', $a['useDt'] ?? '');
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
$totalCount = count($allLogs);
|
||||
$maxPageNum = (int)ceil($totalCount / $limit);
|
||||
$startIndex = ($page - 1) * $limit;
|
||||
$paginatedLogs = array_slice($allLogs, $startIndex, $limit);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $paginatedLogs,
|
||||
'pagination' => [
|
||||
'currentPage' => $page,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => $maxPageNum,
|
||||
'maxIndex' => $totalCount
|
||||
],
|
||||
'summary' => [
|
||||
'totalAmount' => $totalAmount,
|
||||
'count' => $totalCount,
|
||||
'approvalCount' => $approvalCount,
|
||||
'cancelCount' => $cancelCount
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 내역 파싱 (저장된 계정과목 병합)
|
||||
*/
|
||||
private function parseTransactionLogs($resultData, $savedData = null): array
|
||||
{
|
||||
$logs = [];
|
||||
$totalAmount = 0;
|
||||
$approvalCount = 0;
|
||||
$cancelCount = 0;
|
||||
|
||||
$rawLogs = [];
|
||||
if (isset($resultData->CardApprovalLogList) && isset($resultData->CardApprovalLogList->CardApprovalLog)) {
|
||||
$rawLogs = is_array($resultData->CardApprovalLogList->CardApprovalLog)
|
||||
? $resultData->CardApprovalLogList->CardApprovalLog
|
||||
: [$resultData->CardApprovalLogList->CardApprovalLog];
|
||||
}
|
||||
|
||||
foreach ($rawLogs as $log) {
|
||||
$amount = floatval($log->ApprovalAmount ?? 0);
|
||||
$approvalType = $log->ApprovalType ?? '1';
|
||||
|
||||
if ($approvalType === '1') {
|
||||
$totalAmount += $amount;
|
||||
$approvalCount++;
|
||||
} else {
|
||||
$cancelCount++;
|
||||
}
|
||||
|
||||
// 사용일시 파싱
|
||||
$useDT = $log->UseDT ?? '';
|
||||
$useDate = '';
|
||||
$useTime = '';
|
||||
$dateTime = '';
|
||||
|
||||
if (!empty($useDT) && strlen($useDT) >= 8) {
|
||||
$useDate = substr($useDT, 0, 8);
|
||||
if (strlen($useDT) >= 14) {
|
||||
$useTime = substr($useDT, 8, 6);
|
||||
$dateTime = substr($useDT, 0, 4) . '-' . substr($useDT, 4, 2) . '-' . substr($useDT, 6, 2) . ' ' .
|
||||
substr($useDT, 8, 2) . ':' . substr($useDT, 10, 2) . ':' . substr($useDT, 12, 2);
|
||||
} else {
|
||||
$dateTime = substr($useDT, 0, 4) . '-' . substr($useDT, 4, 2) . '-' . substr($useDT, 6, 2);
|
||||
}
|
||||
}
|
||||
|
||||
$cardNum = $log->CardNum ?? '';
|
||||
$approvalNum = $log->ApprovalNum ?? '';
|
||||
|
||||
// 고유 키 생성하여 저장된 데이터와 매칭
|
||||
$uniqueKey = implode('|', [$cardNum, $useDT, $approvalNum, (int) $amount]);
|
||||
$savedItem = $savedData?->get($uniqueKey);
|
||||
|
||||
$logItem = [
|
||||
'cardNum' => $cardNum,
|
||||
'cardNumMasked' => $this->maskCardNumber($cardNum),
|
||||
'cardCompany' => $log->CardCompany ?? '',
|
||||
'cardBrand' => $this->getCardCompanyName($log->CardCompany ?? ''),
|
||||
'useDt' => $useDT,
|
||||
'useDate' => $useDate,
|
||||
'useTime' => $useTime,
|
||||
'useDateTime' => $dateTime,
|
||||
'approvalNum' => $approvalNum,
|
||||
'approvalType' => $approvalType,
|
||||
'approvalTypeName' => $approvalType === '1' ? '승인' : '취소',
|
||||
'approvalAmount' => $amount,
|
||||
'approvalAmountFormatted' => number_format($amount),
|
||||
'tax' => floatval($log->Tax ?? 0),
|
||||
'taxFormatted' => number_format(floatval($log->Tax ?? 0)),
|
||||
'serviceCharge' => floatval($log->ServiceCharge ?? 0),
|
||||
'paymentPlan' => $log->PaymentPlan ?? '0',
|
||||
'paymentPlanName' => $this->getPaymentPlanName($log->PaymentPlan ?? '0'),
|
||||
'currencyCode' => $log->CurrencyCode ?? 'KRW',
|
||||
'merchantName' => $log->UseStoreName ?? '',
|
||||
'merchantBizNum' => $log->UseStoreCorpNum ?? '',
|
||||
'merchantAddr' => $log->UseStoreAddr ?? '',
|
||||
'merchantCeo' => $log->UseStoreCeo ?? '',
|
||||
'merchantBizType' => $log->UseStoreBizType ?? '',
|
||||
'merchantTel' => $log->UseStoreTel ?? '',
|
||||
'memo' => $log->Memo ?? '',
|
||||
'useKey' => $log->UseKey ?? '',
|
||||
// 저장된 계정과목 정보 병합
|
||||
'accountCode' => $savedItem?->account_code ?? '',
|
||||
'accountName' => $savedItem?->account_name ?? '',
|
||||
'isSaved' => $savedItem !== null,
|
||||
];
|
||||
|
||||
$logs[] = $logItem;
|
||||
}
|
||||
|
||||
return [
|
||||
'logs' => $logs,
|
||||
'summary' => [
|
||||
'totalAmount' => $totalAmount,
|
||||
'count' => count($logs),
|
||||
'approvalCount' => $approvalCount,
|
||||
'cancelCount' => $cancelCount
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 코드 체크
|
||||
*/
|
||||
private function checkErrorCode($data): ?int
|
||||
{
|
||||
if (isset($data->CurrentPage) && is_numeric($data->CurrentPage) && $data->CurrentPage < 0) {
|
||||
return (int)$data->CurrentPage;
|
||||
}
|
||||
if (isset($data->CardNum) && is_numeric($data->CardNum) && $data->CardNum < 0) {
|
||||
return (int)$data->CardNum;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 반환
|
||||
*/
|
||||
private function getErrorMessage(int $errorCode): string
|
||||
{
|
||||
$messages = [
|
||||
-10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.',
|
||||
-24005 => '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.',
|
||||
-25001 => '등록된 카드가 없습니다 (-25001).',
|
||||
-25005 => '조회된 데이터가 없습니다 (-25005).',
|
||||
-25006 => '카드번호가 잘못되었습니다 (-25006).',
|
||||
-25007 => '조회 기간이 잘못되었습니다 (-25007).',
|
||||
];
|
||||
return $messages[$errorCode] ?? '바로빌 API 오류: ' . $errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 마스킹
|
||||
*/
|
||||
private function maskCardNumber(string $cardNum): string
|
||||
{
|
||||
if (strlen($cardNum) < 8) return $cardNum;
|
||||
return substr($cardNum, 0, 4) . '-****-****-' . substr($cardNum, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드사 코드 -> 카드사명 변환
|
||||
*/
|
||||
private function getCardCompanyName(string $code): string
|
||||
{
|
||||
$companies = [
|
||||
'01' => '비씨',
|
||||
'02' => 'KB국민',
|
||||
'03' => '하나(외환)',
|
||||
'04' => '삼성',
|
||||
'06' => '신한',
|
||||
'07' => '현대',
|
||||
'08' => '롯데',
|
||||
'11' => 'NH농협',
|
||||
'12' => '수협',
|
||||
'13' => '씨티',
|
||||
'14' => '우리',
|
||||
'15' => '광주',
|
||||
'16' => '전북',
|
||||
'21' => '하나',
|
||||
'22' => '제주',
|
||||
'23' => 'SC제일',
|
||||
'25' => 'KDB산업',
|
||||
'26' => 'IBK기업',
|
||||
'27' => '새마을금고',
|
||||
'28' => '신협',
|
||||
'29' => '저축은행',
|
||||
'30' => '우체국',
|
||||
'31' => '카카오뱅크',
|
||||
'32' => 'K뱅크',
|
||||
'33' => '토스뱅크'
|
||||
];
|
||||
return $companies[$code] ?? $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 할부 이름 반환
|
||||
*/
|
||||
private function getPaymentPlanName(string $plan): string
|
||||
{
|
||||
if (empty($plan) || $plan === '0' || $plan === '00') {
|
||||
return '일시불';
|
||||
}
|
||||
return $plan . '개월';
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 목록 조회
|
||||
*/
|
||||
public function accountCodes(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$codes = AccountCode::getActiveByTenant($tenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $codes->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'code' => $c->code,
|
||||
'name' => $c->name,
|
||||
'category' => $c->category,
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 사용내역 저장 (계정과목 포함)
|
||||
*/
|
||||
public function save(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$transactions = $request->input('transactions', []);
|
||||
|
||||
if (empty($transactions)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '저장할 데이터가 없습니다.'
|
||||
]);
|
||||
}
|
||||
|
||||
$saved = 0;
|
||||
$updated = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
foreach ($transactions as $trans) {
|
||||
$data = [
|
||||
'tenant_id' => $tenantId,
|
||||
'card_num' => $trans['cardNum'] ?? '',
|
||||
'card_company' => $trans['cardCompany'] ?? '',
|
||||
'card_company_name' => $trans['cardBrand'] ?? '',
|
||||
'use_dt' => $trans['useDt'] ?? '',
|
||||
'use_date' => $trans['useDate'] ?? '',
|
||||
'use_time' => $trans['useTime'] ?? '',
|
||||
'approval_num' => $trans['approvalNum'] ?? '',
|
||||
'approval_type' => $trans['approvalType'] ?? '',
|
||||
'approval_amount' => floatval($trans['approvalAmount'] ?? 0),
|
||||
'tax' => floatval($trans['tax'] ?? 0),
|
||||
'service_charge' => floatval($trans['serviceCharge'] ?? 0),
|
||||
'payment_plan' => $trans['paymentPlan'] ?? '',
|
||||
'currency_code' => $trans['currencyCode'] ?? 'KRW',
|
||||
'merchant_name' => $trans['merchantName'] ?? '',
|
||||
'merchant_biz_num' => $trans['merchantBizNum'] ?? '',
|
||||
'merchant_addr' => $trans['merchantAddr'] ?? '',
|
||||
'merchant_ceo' => $trans['merchantCeo'] ?? '',
|
||||
'merchant_biz_type' => $trans['merchantBizType'] ?? '',
|
||||
'merchant_tel' => $trans['merchantTel'] ?? '',
|
||||
'memo' => $trans['memo'] ?? '',
|
||||
'use_key' => $trans['useKey'] ?? '',
|
||||
'account_code' => $trans['accountCode'] ?? null,
|
||||
'account_name' => $trans['accountName'] ?? null,
|
||||
];
|
||||
|
||||
// Upsert: 있으면 업데이트, 없으면 생성
|
||||
$existing = CardTransaction::where('tenant_id', $tenantId)
|
||||
->where('card_num', $data['card_num'])
|
||||
->where('use_dt', $data['use_dt'])
|
||||
->where('approval_num', $data['approval_num'])
|
||||
->where('approval_amount', $data['approval_amount'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// 계정과목만 업데이트
|
||||
$existing->update([
|
||||
'account_code' => $data['account_code'],
|
||||
'account_name' => $data['account_name'],
|
||||
]);
|
||||
$updated++;
|
||||
} else {
|
||||
CardTransaction::create($data);
|
||||
$saved++;
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "저장 완료: 신규 {$saved}건, 수정 {$updated}건",
|
||||
'saved' => $saved,
|
||||
'updated' => $updated
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('카드 사용내역 저장 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '저장 오류: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
public function exportExcel(Request $request): StreamedResponse|JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$startDate = $request->input('startDate', date('Ymd'));
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$cardNum = $request->input('cardNum', '');
|
||||
|
||||
// DB에서 저장된 데이터 조회
|
||||
$query = CardTransaction::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate])
|
||||
->orderBy('use_date', 'desc')
|
||||
->orderBy('use_time', 'desc');
|
||||
|
||||
if (!empty($cardNum)) {
|
||||
$query->where('card_num', $cardNum);
|
||||
}
|
||||
|
||||
$transactions = $query->get();
|
||||
|
||||
// 데이터가 없으면 안내
|
||||
if ($transactions->isEmpty()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '저장된 데이터가 없습니다. 먼저 데이터를 조회하고 저장해주세요.'
|
||||
]);
|
||||
}
|
||||
|
||||
$filename = "카드사용내역_{$startDate}_{$endDate}.csv";
|
||||
|
||||
return response()->streamDownload(function () use ($transactions) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// UTF-8 BOM for Excel
|
||||
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// 헤더
|
||||
fputcsv($handle, [
|
||||
'사용일시',
|
||||
'카드번호',
|
||||
'카드사',
|
||||
'가맹점명',
|
||||
'가맹점사업자번호',
|
||||
'승인금액',
|
||||
'부가세',
|
||||
'할부',
|
||||
'구분',
|
||||
'승인번호',
|
||||
'계정과목코드',
|
||||
'계정과목명'
|
||||
]);
|
||||
|
||||
// 데이터
|
||||
foreach ($transactions as $trans) {
|
||||
$dateTime = '';
|
||||
if ($trans->use_date) {
|
||||
$dateTime = substr($trans->use_date, 0, 4) . '-' .
|
||||
substr($trans->use_date, 4, 2) . '-' .
|
||||
substr($trans->use_date, 6, 2);
|
||||
if ($trans->use_time) {
|
||||
$dateTime .= ' ' . substr($trans->use_time, 0, 2) . ':' .
|
||||
substr($trans->use_time, 2, 2) . ':' .
|
||||
substr($trans->use_time, 4, 2);
|
||||
}
|
||||
}
|
||||
|
||||
fputcsv($handle, [
|
||||
$dateTime,
|
||||
$trans->card_num,
|
||||
$trans->card_company_name,
|
||||
$trans->merchant_name,
|
||||
$trans->merchant_biz_num,
|
||||
$trans->approval_amount,
|
||||
$trans->tax,
|
||||
$this->getPaymentPlanName($trans->payment_plan),
|
||||
$trans->approval_type === '1' ? '승인' : '취소',
|
||||
$trans->approval_num,
|
||||
$trans->account_code,
|
||||
$trans->account_name
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('엑셀 다운로드 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '다운로드 오류: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SOAP 호출
|
||||
*/
|
||||
private function callSoap(string $method, array $params = []): array
|
||||
{
|
||||
if (!$this->soapClient) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다.'
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($this->certKey) && !$this->isTestMode) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'CERTKEY가 설정되지 않았습니다.'
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($this->corpNum)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '사업자번호가 설정되지 않았습니다.'
|
||||
];
|
||||
}
|
||||
|
||||
// CERTKEY와 CorpNum 자동 추가
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $this->certKey ?? '';
|
||||
}
|
||||
if (!isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $this->corpNum;
|
||||
}
|
||||
|
||||
try {
|
||||
Log::info("바로빌 카드 API 호출 - Method: {$method}, CorpNum: {$this->corpNum}");
|
||||
|
||||
$result = $this->soapClient->$method($params);
|
||||
$resultProperty = $method . 'Result';
|
||||
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
// 에러 코드 체크
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage((int)$resultData),
|
||||
'error_code' => (int)$resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
];
|
||||
} catch (\SoapFault $e) {
|
||||
Log::error('바로빌 SOAP 오류: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 API 호출 오류: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
99
app/Models/Barobill/CardTransaction.php
Normal file
99
app/Models/Barobill/CardTransaction.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\Tenants\Tenant;
|
||||
|
||||
/**
|
||||
* 바로빌 카드 사용내역 모델
|
||||
*/
|
||||
class CardTransaction extends Model
|
||||
{
|
||||
protected $table = 'barobill_card_transactions';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'card_num',
|
||||
'card_company',
|
||||
'card_company_name',
|
||||
'use_dt',
|
||||
'use_date',
|
||||
'use_time',
|
||||
'approval_num',
|
||||
'approval_type',
|
||||
'approval_amount',
|
||||
'tax',
|
||||
'service_charge',
|
||||
'payment_plan',
|
||||
'currency_code',
|
||||
'merchant_name',
|
||||
'merchant_biz_num',
|
||||
'merchant_addr',
|
||||
'merchant_ceo',
|
||||
'merchant_biz_type',
|
||||
'merchant_tel',
|
||||
'memo',
|
||||
'use_key',
|
||||
'account_code',
|
||||
'account_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'approval_amount' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'service_charge' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 고유 키 생성 (매칭용)
|
||||
*/
|
||||
public function getUniqueKeyAttribute(): string
|
||||
{
|
||||
return implode('|', [
|
||||
$this->card_num,
|
||||
$this->use_dt,
|
||||
$this->approval_num,
|
||||
(int) $this->approval_amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 로그 데이터로부터 고유 키 생성 (정적 메서드)
|
||||
*/
|
||||
public static function generateUniqueKey(array $log): string
|
||||
{
|
||||
return implode('|', [
|
||||
$log['cardNum'] ?? '',
|
||||
$log['useDt'] ?? '',
|
||||
$log['approvalNum'] ?? '',
|
||||
(int) ($log['approvalAmount'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 거래 내역 조회 (기간별)
|
||||
*/
|
||||
public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $cardNum = null)
|
||||
{
|
||||
$query = self::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate])
|
||||
->orderBy('use_date', 'desc')
|
||||
->orderBy('use_time', 'desc');
|
||||
|
||||
if ($cardNum) {
|
||||
$query->where('card_num', $cardNum);
|
||||
}
|
||||
|
||||
return $query->get()->keyBy(fn($item) => $item->unique_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('barobill_card_transactions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
|
||||
$table->string('card_num', 50)->comment('카드번호');
|
||||
$table->string('card_company', 10)->nullable()->comment('카드사 코드');
|
||||
$table->string('card_company_name', 50)->nullable()->comment('카드사명');
|
||||
$table->string('use_dt', 20)->comment('사용일시 원본 (YYYYMMDDHHMMSS)');
|
||||
$table->string('use_date', 8)->comment('사용일 (YYYYMMDD)');
|
||||
$table->string('use_time', 6)->nullable()->comment('사용시간 (HHMMSS)');
|
||||
$table->string('approval_num', 50)->nullable()->comment('승인번호');
|
||||
$table->string('approval_type', 10)->nullable()->comment('승인유형 (1=승인, 2=취소)');
|
||||
$table->decimal('approval_amount', 18, 2)->default(0)->comment('승인금액');
|
||||
$table->decimal('tax', 18, 2)->default(0)->comment('부가세');
|
||||
$table->decimal('service_charge', 18, 2)->default(0)->comment('봉사료');
|
||||
$table->string('payment_plan', 10)->nullable()->comment('할부개월수');
|
||||
$table->string('currency_code', 10)->default('KRW')->comment('통화코드');
|
||||
$table->string('merchant_name', 255)->nullable()->comment('가맹점명');
|
||||
$table->string('merchant_biz_num', 20)->nullable()->comment('가맹점 사업자번호');
|
||||
$table->string('merchant_addr', 255)->nullable()->comment('가맹점 주소');
|
||||
$table->string('merchant_ceo', 100)->nullable()->comment('가맹점 대표자');
|
||||
$table->string('merchant_biz_type', 100)->nullable()->comment('가맹점 업종');
|
||||
$table->string('merchant_tel', 50)->nullable()->comment('가맹점 전화번호');
|
||||
$table->string('memo', 255)->nullable()->comment('메모');
|
||||
$table->string('use_key', 100)->nullable()->comment('사용키');
|
||||
$table->string('account_code', 50)->nullable()->comment('계정과목 코드');
|
||||
$table->string('account_name', 100)->nullable()->comment('계정과목 명');
|
||||
$table->timestamps();
|
||||
|
||||
// 복합 유니크 인덱스: 같은 거래는 중복 저장 방지
|
||||
$table->unique(
|
||||
['tenant_id', 'card_num', 'use_dt', 'approval_num', 'approval_amount'],
|
||||
'bb_card_trans_unique'
|
||||
);
|
||||
|
||||
// 조회용 인덱스
|
||||
$table->index(['tenant_id', 'use_date'], 'bb_card_trans_tenant_date_idx');
|
||||
$table->index(['tenant_id', 'card_num', 'use_date'], 'bb_card_trans_tenant_card_date_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('barobill_card_transactions');
|
||||
}
|
||||
};
|
||||
@@ -592,11 +592,11 @@ protected function seedMainMenus(): void
|
||||
]);
|
||||
$this->createMenu([
|
||||
'parent_id' => $barobillGroup->id,
|
||||
'name' => '카드사용내역',
|
||||
'url' => '/barobill/card-usage',
|
||||
'name' => '카드 사용내역',
|
||||
'url' => '/barobill/ecard',
|
||||
'icon' => 'credit-card',
|
||||
'sort_order' => $barobillSubOrder++,
|
||||
'options' => ['route_name' => 'barobill.card-usage.index', 'section' => 'main'],
|
||||
'options' => ['route_name' => 'barobill.ecard.index', 'section' => 'main'],
|
||||
]);
|
||||
$this->createMenu([
|
||||
'parent_id' => $barobillGroup->id,
|
||||
|
||||
765
resources/views/barobill/ecard/index.blade.php
Normal file
765
resources/views/barobill/ecard/index.blade.php
Normal file
@@ -0,0 +1,765 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '카드 사용내역')
|
||||
|
||||
@section('content')
|
||||
<!-- 현재 테넌트 정보 카드 (React 외부) -->
|
||||
@if($currentTenant)
|
||||
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #7c3aed, #8b5cf6); color: white;">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
|
||||
<svg 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="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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
|
||||
@if($currentTenant->id == 1)
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
|
||||
@endif
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@if($barobillMember)
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">사업자번호</p>
|
||||
<p class="font-medium">{{ $barobillMember->biz_no }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">대표자</p>
|
||||
<p class="font-medium">{{ $barobillMember->ceo_name ?? '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">담당자</p>
|
||||
<p class="font-medium">{{ $barobillMember->manager_name ?? '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">바로빌 ID</p>
|
||||
<p class="font-medium">{{ $barobillMember->barobill_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center gap-2" style="color: #fef08a;">
|
||||
<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 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>
|
||||
<span class="text-sm">바로빌 회원사 미연동</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div id="ecard-root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef, useCallback } = React;
|
||||
|
||||
// API Routes
|
||||
const API = {
|
||||
cards: '{{ route("barobill.ecard.cards") }}',
|
||||
transactions: '{{ route("barobill.ecard.transactions") }}',
|
||||
accountCodes: '{{ route("barobill.ecard.account-codes") }}',
|
||||
save: '{{ route("barobill.ecard.save") }}',
|
||||
export: '{{ route("barobill.ecard.export") }}',
|
||||
};
|
||||
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
|
||||
// 날짜 유틸리티 함수
|
||||
const getMonthDates = (offset = 0) => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + offset;
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
return {
|
||||
from: firstDay.toISOString().split('T')[0],
|
||||
to: lastDay.toISOString().split('T')[0]
|
||||
};
|
||||
};
|
||||
|
||||
// Toast 알림 (전역 showToast 사용)
|
||||
const notify = (message, type = 'info') => {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, type);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
// StatCard Component
|
||||
const StatCard = ({ title, value, subtext, icon, color = 'purple' }) => {
|
||||
const colorClasses = {
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
red: 'bg-red-50 text-red-600',
|
||||
stone: 'bg-stone-50 text-stone-600'
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-stone-500">{title}</h3>
|
||||
<div className={`p-2 rounded-lg ${colorClasses[color] || colorClasses.purple}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-stone-900 mb-1">{value}</div>
|
||||
{subtext && <div className="text-xs text-stone-400">{subtext}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// CardSelector Component
|
||||
const CardSelector = ({ cards, selectedCard, onSelect }) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onSelect('')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCard === ''
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
전체 카드
|
||||
</button>
|
||||
{cards.map(card => (
|
||||
<button
|
||||
key={card.cardNum}
|
||||
onClick={() => onSelect(card.cardNum)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCard === card.cardNum
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
{card.cardBrand} {card.cardNum ? '****' + card.cardNum.slice(-4) : ''}
|
||||
{card.alias && ` (${card.alias})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// AccountCodeSelect Component (검색 가능한 드롭다운)
|
||||
const AccountCodeSelect = ({ value, onChange, accountCodes }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 선택된 값의 표시 텍스트
|
||||
const selectedItem = accountCodes.find(c => c.code === value);
|
||||
const displayText = selectedItem ? `${selectedItem.code} ${selectedItem.name}` : '';
|
||||
|
||||
// 검색 필터링
|
||||
const filteredCodes = accountCodes.filter(code => {
|
||||
if (!search) return true;
|
||||
const searchLower = search.toLowerCase();
|
||||
return code.code.toLowerCase().includes(searchLower) ||
|
||||
code.name.toLowerCase().includes(searchLower);
|
||||
});
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (code) => {
|
||||
const selected = accountCodes.find(c => c.code === code.code);
|
||||
onChange(code.code, selected?.name || '');
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleClear = (e) => {
|
||||
e.stopPropagation();
|
||||
onChange('', '');
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* 선택 버튼 */}
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full px-2 py-1 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${
|
||||
isOpen ? 'border-purple-500 ring-2 ring-purple-500' : 'border-stone-200'
|
||||
} bg-white`}
|
||||
>
|
||||
<span className={displayText ? 'text-stone-900' : 'text-stone-400'}>
|
||||
{displayText || '선택'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{value && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<svg className={`w-3 h-3 text-stone-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-48 bg-white border border-stone-200 rounded-lg shadow-lg">
|
||||
{/* 검색 입력 */}
|
||||
<div className="p-2 border-b border-stone-100">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="코드 또는 이름 검색..."
|
||||
className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{/* 옵션 목록 */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredCodes.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-stone-400 text-center">
|
||||
검색 결과 없음
|
||||
</div>
|
||||
) : (
|
||||
filteredCodes.slice(0, 50).map(code => (
|
||||
<div
|
||||
key={code.code}
|
||||
onClick={() => handleSelect(code)}
|
||||
className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-purple-50 ${
|
||||
value === code.code ? 'bg-purple-100 text-purple-700' : 'text-stone-700'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-purple-600">{code.code}</span>
|
||||
<span className="ml-1">{code.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{filteredCodes.length > 50 && (
|
||||
<div className="px-3 py-1 text-xs text-stone-400 text-center border-t">
|
||||
+{filteredCodes.length - 50}개 더 있음
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TransactionTable Component
|
||||
const TransactionTable = ({
|
||||
logs,
|
||||
loading,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
onDateFromChange,
|
||||
onDateToChange,
|
||||
onThisMonth,
|
||||
onLastMonth,
|
||||
totalCount,
|
||||
accountCodes,
|
||||
onAccountCodeChange,
|
||||
onSave,
|
||||
onExport,
|
||||
saving,
|
||||
hasChanges
|
||||
}) => {
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-100">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<h2 className="text-lg font-bold text-stone-900">카드 사용내역</h2>
|
||||
{/* 기간 조회 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-stone-500">기간</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => onDateFromChange(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
<span className="text-stone-400">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => onDateToChange(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onThisMonth}
|
||||
className="px-3 py-1.5 text-sm bg-purple-50 text-purple-600 rounded-lg hover:bg-purple-100 transition-colors font-medium"
|
||||
>
|
||||
이번 달
|
||||
</button>
|
||||
<button
|
||||
onClick={onLastMonth}
|
||||
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
|
||||
>
|
||||
지난달
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-stone-500 ml-2">
|
||||
조회: <span className="font-semibold text-stone-700">{logs.length}</span>건
|
||||
{totalCount !== logs.length && (
|
||||
<span className="text-stone-400"> / 전체 {totalCount}건</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 저장/엑셀 버튼 */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving || logs.length === 0}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
hasChanges
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-purple-100 text-purple-700 hover:bg-purple-200'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{saving ? (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||
)}
|
||||
{hasChanges ? '변경사항 저장' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={logs.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto" style={ {maxHeight: '500px', overflowY: 'auto'} }>
|
||||
<table className="w-full text-left text-sm text-stone-600">
|
||||
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-4 bg-stone-50">사용일시</th>
|
||||
<th className="px-4 py-4 bg-stone-50">카드정보</th>
|
||||
<th className="px-4 py-4 bg-stone-50">가맹점</th>
|
||||
<th className="px-4 py-4 text-right bg-stone-50">금액</th>
|
||||
<th className="px-4 py-4 text-right bg-stone-50">부가세</th>
|
||||
<th className="px-4 py-4 bg-stone-50">할부</th>
|
||||
<th className="px-4 py-4 bg-stone-50">구분</th>
|
||||
<th className="px-4 py-4 bg-stone-50">승인번호</th>
|
||||
<th className="px-4 py-4 bg-stone-50 min-w-[150px]">계정과목</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="px-6 py-8 text-center text-stone-400">
|
||||
해당 기간에 조회된 카드 사용내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<tr key={index} className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-purple-50/30' : ''}`}>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="font-medium text-stone-900">{log.useDateTime || '-'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-stone-900">{log.cardBrand}</div>
|
||||
<div className="text-xs text-stone-400 font-mono">
|
||||
{log.cardNumMasked || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-stone-900">{log.merchantName || '-'}</div>
|
||||
{log.merchantBizNum && (
|
||||
<div className="text-xs text-stone-400">{log.merchantBizNum}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
<span className={log.approvalType === '1' ? 'text-purple-600' : 'text-red-600'}>
|
||||
{log.approvalAmountFormatted}원
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-stone-500">
|
||||
{log.taxFormatted}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
log.paymentPlanName === '일시불'
|
||||
? 'bg-stone-100 text-stone-600'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{log.paymentPlanName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
log.approvalType === '1'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{log.approvalTypeName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-stone-500 font-mono text-xs">
|
||||
{log.approvalNum || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<AccountCodeSelect
|
||||
value={log.accountCode}
|
||||
onChange={(code, name) => onAccountCodeChange(index, code, name)}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
{log.accountName && (
|
||||
<div className="text-xs text-purple-600 mt-1">{log.accountName}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main App Component
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cards, setCards] = useState([]);
|
||||
const [selectedCard, setSelectedCard] = useState('');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [summary, setSummary] = useState({});
|
||||
const [pagination, setPagination] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [accountCodes, setAccountCodes] = useState([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// 날짜 필터 상태 (기본: 현재 월)
|
||||
const currentMonth = getMonthDates(0);
|
||||
const [dateFrom, setDateFrom] = useState(currentMonth.from);
|
||||
const [dateTo, setDateTo] = useState(currentMonth.to);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadCards();
|
||||
loadAccountCodes();
|
||||
}, []);
|
||||
|
||||
// 날짜 또는 카드 변경 시 거래내역 로드
|
||||
useEffect(() => {
|
||||
if (dateFrom && dateTo) {
|
||||
loadTransactions();
|
||||
}
|
||||
}, [dateFrom, dateTo, selectedCard]);
|
||||
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
const response = await fetch(API.cards);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setCards(data.cards || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('카드 목록 로드 오류:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAccountCodes = async () => {
|
||||
try {
|
||||
const response = await fetch(API.accountCodes);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAccountCodes(data.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('계정과목 목록 로드 오류:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTransactions = async (page = 1) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setHasChanges(false);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateFrom.replace(/-/g, ''),
|
||||
endDate: dateTo.replace(/-/g, ''),
|
||||
cardNum: selectedCard,
|
||||
page: page,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
const response = await fetch(`${API.transactions}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setLogs(data.data?.logs || []);
|
||||
setPagination(data.data?.pagination || {});
|
||||
setSummary(data.data?.summary || {});
|
||||
} else {
|
||||
setError(data.error || '조회 실패');
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('서버 통신 오류: ' + err.message);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 계정과목 변경 핸들러
|
||||
const handleAccountCodeChange = useCallback((index, code, name) => {
|
||||
setLogs(prevLogs => {
|
||||
const newLogs = [...prevLogs];
|
||||
newLogs[index] = {
|
||||
...newLogs[index],
|
||||
accountCode: code,
|
||||
accountName: name
|
||||
};
|
||||
return newLogs;
|
||||
});
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (logs.length === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(API.save, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ transactions: logs })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
notify(data.message, 'success');
|
||||
setHasChanges(false);
|
||||
// 저장 후 다시 로드하여 isSaved 상태 갱신
|
||||
loadTransactions();
|
||||
} else {
|
||||
notify(data.error || '저장 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
notify('저장 오류: ' + err.message, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드 핸들러
|
||||
const handleExport = () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateFrom.replace(/-/g, ''),
|
||||
endDate: dateTo.replace(/-/g, ''),
|
||||
cardNum: selectedCard
|
||||
});
|
||||
window.location.href = `${API.export}?${params}`;
|
||||
};
|
||||
|
||||
// 이번 달 버튼
|
||||
const handleThisMonth = () => {
|
||||
const dates = getMonthDates(0);
|
||||
setDateFrom(dates.from);
|
||||
setDateTo(dates.to);
|
||||
};
|
||||
|
||||
// 지난달 버튼
|
||||
const handleLastMonth = () => {
|
||||
const dates = getMonthDates(-1);
|
||||
setDateFrom(dates.from);
|
||||
setDateTo(dates.to);
|
||||
};
|
||||
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-900">카드 사용내역</h1>
|
||||
<p className="text-stone-500 mt-1">바로빌 API를 통한 카드 사용내역 조회 및 계정과목 관리</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@if($isTestMode)
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
|
||||
@endif
|
||||
@if($hasSoapClient)
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
|
||||
@else
|
||||
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">SOAP 미연결</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="총 사용금액"
|
||||
value={formatCurrency(summary.totalAmount)}
|
||||
subtext="조회기간 합계"
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" 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>}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="승인건수"
|
||||
value={`${(summary.approvalCount || 0).toLocaleString()}건`}
|
||||
subtext="조회기간 합계"
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="취소건수"
|
||||
value={`${(summary.cancelCount || 0).toLocaleString()}건`}
|
||||
subtext="조회기간 합계"
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>}
|
||||
color="red"
|
||||
/>
|
||||
<StatCard
|
||||
title="등록된 카드"
|
||||
value={`${cards.length}개`}
|
||||
subtext="사용 가능한 카드"
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 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>}
|
||||
color="stone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card Filter */}
|
||||
{cards.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
|
||||
<h2 className="text-sm font-medium text-stone-700 mb-3">카드 선택</h2>
|
||||
<CardSelector
|
||||
cards={cards}
|
||||
selectedCard={selectedCard}
|
||||
onSelect={setSelectedCard}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-xl">⚠️</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold mb-2">{error}</p>
|
||||
{error.includes('-25') && (
|
||||
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
|
||||
<p className="font-medium mb-2">해결 방법:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-stone-700">
|
||||
<li>바로빌 사이트(<a href="https://www.barobill.co.kr" target="_blank" className="text-blue-600 hover:underline">https://www.barobill.co.kr</a>)에 로그인</li>
|
||||
<li>카드 관리 메뉴에서 해당 카드 확인</li>
|
||||
<li>카드 인증 정보가 만료되지 않았는지 확인</li>
|
||||
<li>필요시 카드 재등록</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction Table */}
|
||||
{!error && (
|
||||
<TransactionTable
|
||||
logs={logs}
|
||||
loading={loading}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onDateFromChange={setDateFrom}
|
||||
onDateToChange={setDateTo}
|
||||
onThisMonth={handleThisMonth}
|
||||
onLastMonth={handleLastMonth}
|
||||
totalCount={summary.count || logs.length}
|
||||
accountCodes={accountCodes}
|
||||
onAccountCodeChange={handleAccountCodeChange}
|
||||
onSave={handleSave}
|
||||
onExport={handleExport}
|
||||
saving={saving}
|
||||
hasChanges={hasChanges}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!error && pagination.maxPageNum > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => loadTransactions(Math.max(1, pagination.currentPage - 1))}
|
||||
disabled={pagination.currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white border disabled:opacity-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
{pagination.currentPage} / {pagination.maxPageNum}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => loadTransactions(Math.min(pagination.maxPageNum, pagination.currentPage + 1))}
|
||||
disabled={pagination.currentPage === pagination.maxPageNum}
|
||||
className="px-3 py-1 rounded bg-white border disabled:opacity-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('ecard-root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
@endpush
|
||||
@@ -300,6 +300,16 @@
|
||||
Route::post('/save', [\App\Http\Controllers\Barobill\EaccountController::class, 'save'])->name('save');
|
||||
Route::get('/export', [\App\Http\Controllers\Barobill\EaccountController::class, 'exportExcel'])->name('export');
|
||||
});
|
||||
|
||||
// 카드 사용내역 (React 페이지)
|
||||
Route::prefix('ecard')->name('ecard.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Barobill\EcardController::class, 'index'])->name('index');
|
||||
Route::get('/cards', [\App\Http\Controllers\Barobill\EcardController::class, 'cards'])->name('cards');
|
||||
Route::get('/transactions', [\App\Http\Controllers\Barobill\EcardController::class, 'transactions'])->name('transactions');
|
||||
Route::get('/account-codes', [\App\Http\Controllers\Barobill\EcardController::class, 'accountCodes'])->name('account-codes');
|
||||
Route::post('/save', [\App\Http\Controllers\Barobill\EcardController::class, 'save'])->name('save');
|
||||
Route::get('/export', [\App\Http\Controllers\Barobill\EcardController::class, 'exportExcel'])->name('export');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user