feat:계좌 입출금내역 조회 페이지 추가

- EaccountController.php: 바로빌 BANKACCOUNT.asmx SOAP API 연동
  - GetBankAccountEx: 등록된 계좌 목록 조회
  - GetPeriodBankAccountTransLog: 계좌 입출금내역 조회
- index.blade.php: React 기반 UI (전자세금계산서와 동일 구조)
  - 테넌트 정보 카드
  - 통계 카드 (입금/출금/계좌수/거래건수)
  - 계좌 선택 버튼
  - 기간 조회 필터 (이번달/지난달 버튼)
  - 입출금 내역 테이블 (스크롤)
- 라우트 추가: /barobill/eaccount
- 메뉴 시더 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-23 10:13:28 +09:00
parent 33fc51e1ab
commit 71080389c8
4 changed files with 1073 additions and 3 deletions

View File

@@ -0,0 +1,587 @@
<?php
namespace App\Http\Controllers\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillConfig;
use App\Models\Barobill\BarobillMember;
use App\Models\Tenants\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
* 바로빌 계좌 입출금내역 조회 컨트롤러
*/
class EaccountController 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';
// 계좌 조회는 BANKACCOUNT.asmx 사용
$baseUrl = $this->isTestMode
? 'https://testws.baroservice.com'
: 'https://ws.baroservice.com';
$this->soapUrl = $baseUrl . '/BANKACCOUNT.asmx?WSDL';
} else {
$this->certKey = config('services.barobill.cert_key', '');
$this->corpNum = config('services.barobill.corp_num', '');
$this->isTestMode = config('services.barobill.test_mode', true);
$this->soapUrl = $this->isTestMode
? 'https://testws.baroservice.com/BANKACCOUNT.asmx?WSDL'
: 'https://ws.baroservice.com/BANKACCOUNT.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.eaccount.index'));
}
// 현재 선택된 테넌트 정보
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$currentTenant = Tenant::find($tenantId);
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
return view('barobill.eaccount.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
'isTestMode' => $this->isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
/**
* 등록된 계좌 목록 조회 (GetBankAccountEx)
*/
public function accounts(Request $request): JsonResponse
{
try {
$availOnly = $request->input('availOnly', 0);
// 현재 테넌트의 바로빌 회원 정보 조회
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
// 바로빌 사용자 ID 결정
$userId = $barobillMember?->barobill_id ?? '';
$result = $this->callSoap('GetBankAccountEx', [
'AvailOnly' => (int)$availOnly
]);
if (!$result['success']) {
return response()->json([
'success' => false,
'error' => $result['error'],
'error_code' => $result['error_code'] ?? null
]);
}
$accounts = [];
$data = $result['data'];
// BankAccount 또는 BankAccountEx에서 계좌 목록 추출
$accountList = [];
if (isset($data->BankAccount)) {
$accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount];
} elseif (isset($data->BankAccountEx)) {
$accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx];
}
foreach ($accountList as $acc) {
if (!is_object($acc)) continue;
$bankAccountNum = $acc->BankAccountNum ?? '';
if (empty($bankAccountNum) || (is_numeric($bankAccountNum) && $bankAccountNum < 0)) {
continue;
}
$bankCode = $acc->BankCode ?? '';
$bankName = $acc->BankName ?? $this->getBankName($bankCode);
$accounts[] = [
'bankAccountNum' => $bankAccountNum,
'bankCode' => $bankCode,
'bankName' => $bankName,
'accountName' => $acc->AccountName ?? '',
'accountType' => $acc->AccountType ?? '',
'currency' => $acc->Currency ?? 'KRW',
'issueDate' => $acc->IssueDate ?? '',
'balance' => $acc->Balance ?? 0,
'status' => isset($acc->UseState) ? (int)$acc->UseState : 1
];
}
return response()->json([
'success' => true,
'accounts' => $accounts,
'count' => count($accounts)
]);
} catch (\Throwable $e) {
Log::error('계좌 목록 조회 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '서버 오류: ' . $e->getMessage()
]);
}
}
/**
* 계좌 입출금내역 조회 (GetPeriodBankAccountTransLog)
*/
public function transactions(Request $request): JsonResponse
{
try {
$startDate = $request->input('startDate', date('Ymd'));
$endDate = $request->input('endDate', date('Ymd'));
$bankAccountNum = str_replace('-', '', $request->input('accountNum', ''));
$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 ?? '';
// 전체 계좌 조회: 빈 값이면 모든 계좌의 거래 내역 조회
if (empty($bankAccountNum)) {
return $this->getAllAccountsTransactions($userId, $startDate, $endDate, $page, $limit);
}
// 단일 계좌 조회
$result = $this->callSoap('GetPeriodBankAccountTransLog', [
'ID' => $userId,
'BankAccountNum' => $bankAccountNum,
'StartDate' => $startDate,
'EndDate' => $endDate,
'TransDirection' => 1, // 1:전체
'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' => ['totalDeposit' => 0, 'totalWithdraw' => 0, 'count' => 0],
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
]
]);
}
// 데이터 파싱
$logs = $this->parseTransactionLogs($resultData);
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 getAllAccountsTransactions(string $userId, string $startDate, string $endDate, int $page, int $limit): JsonResponse
{
// 먼저 계좌 목록 조회
$accountResult = $this->callSoap('GetBankAccountEx', ['AvailOnly' => 0]);
if (!$accountResult['success']) {
return response()->json([
'success' => false,
'error' => $accountResult['error']
]);
}
$accountList = [];
$data = $accountResult['data'];
if (isset($data->BankAccount)) {
$accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount];
} elseif (isset($data->BankAccountEx)) {
$accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx];
}
$allLogs = [];
$totalDeposit = 0;
$totalWithdraw = 0;
foreach ($accountList as $acc) {
if (!is_object($acc)) continue;
$accNum = $acc->BankAccountNum ?? '';
if (empty($accNum) || (is_numeric($accNum) && $accNum < 0)) continue;
$accResult = $this->callSoap('GetPeriodBankAccountTransLog', [
'ID' => $userId,
'BankAccountNum' => $accNum,
'StartDate' => $startDate,
'EndDate' => $endDate,
'TransDirection' => 1,
'CountPerPage' => 1000,
'CurrentPage' => 1,
'OrderDirection' => 2
]);
if ($accResult['success']) {
$accData = $accResult['data'];
$errorCode = $this->checkErrorCode($accData);
if (!$errorCode || in_array($errorCode, [-25005, -25001])) {
$parsed = $this->parseTransactionLogs($accData, $acc->BankName ?? '');
foreach ($parsed['logs'] as $log) {
$log['bankName'] = $acc->BankName ?? $this->getBankName($acc->BankCode ?? '');
$allLogs[] = $log;
}
$totalDeposit += $parsed['summary']['totalDeposit'];
$totalWithdraw += $parsed['summary']['totalWithdraw'];
}
}
}
// 날짜/시간 기준 정렬 (최신순)
usort($allLogs, function ($a, $b) {
$dateA = ($a['transDate'] ?? '') . ($a['transTime'] ?? '');
$dateB = ($b['transDate'] ?? '') . ($b['transTime'] ?? '');
return strcmp($dateB, $dateA);
});
// 페이지네이션
$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' => [
'totalDeposit' => $totalDeposit,
'totalWithdraw' => $totalWithdraw,
'count' => $totalCount
]
]
]);
}
/**
* 거래 내역 파싱
*/
private function parseTransactionLogs($resultData, string $defaultBankName = ''): array
{
$logs = [];
$totalDeposit = 0;
$totalWithdraw = 0;
$rawLogs = [];
if (isset($resultData->BankAccountLogList) && isset($resultData->BankAccountLogList->BankAccountTransLog)) {
$rawLogs = is_array($resultData->BankAccountLogList->BankAccountTransLog)
? $resultData->BankAccountLogList->BankAccountTransLog
: [$resultData->BankAccountLogList->BankAccountTransLog];
}
foreach ($rawLogs as $log) {
$deposit = floatval($log->Deposit ?? 0);
$withdraw = floatval($log->Withdraw ?? 0);
$totalDeposit += $deposit;
$totalWithdraw += $withdraw;
// 거래일시 파싱
$transDT = $log->TransDT ?? '';
$transDate = '';
$transTime = '';
$dateTime = '';
if (!empty($transDT) && strlen($transDT) >= 14) {
$transDate = substr($transDT, 0, 8);
$transTime = substr($transDT, 8, 6);
$dateTime = substr($transDT, 0, 4) . '-' . substr($transDT, 4, 2) . '-' . substr($transDT, 6, 2) . ' ' .
substr($transDT, 8, 2) . ':' . substr($transDT, 10, 2) . ':' . substr($transDT, 12, 2);
}
// 적요 파싱
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
$remark2 = $log->TransRemark2 ?? '';
$transType = $log->TransType ?? '';
$fullSummary = $summary;
if (!empty($remark2)) {
$fullSummary = $fullSummary ? $fullSummary . ' ' . $remark2 : $remark2;
}
$logs[] = [
'transDate' => $transDate,
'transTime' => $transTime,
'transDateTime' => $dateTime,
'bankAccountNum' => $log->BankAccountNum ?? '',
'bankName' => $log->BankName ?? $defaultBankName,
'deposit' => $deposit,
'withdraw' => $withdraw,
'depositFormatted' => number_format($deposit),
'withdrawFormatted' => number_format($withdraw),
'balance' => floatval($log->Balance ?? 0),
'balanceFormatted' => number_format(floatval($log->Balance ?? 0)),
'summary' => $fullSummary,
'cast' => $log->Cast ?? '',
'memo' => $log->Memo ?? '',
'transOffice' => $log->TransOffice ?? ''
];
}
return [
'logs' => $logs,
'summary' => [
'totalDeposit' => $totalDeposit,
'totalWithdraw' => $totalWithdraw,
'count' => count($logs)
]
];
}
/**
* 에러 코드 체크
*/
private function checkErrorCode($data): ?int
{
if (isset($data->CurrentPage) && is_numeric($data->CurrentPage) && $data->CurrentPage < 0) {
return (int)$data->CurrentPage;
}
if (isset($data->BankAccountNum) && is_numeric($data->BankAccountNum) && $data->BankAccountNum < 0) {
return (int)$data->BankAccountNum;
}
return null;
}
/**
* 에러 메시지 반환
*/
private function getErrorMessage(int $errorCode): string
{
$messages = [
-10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.',
-50214 => '은행 로그인 실패 (-50214). 바로빌 사이트에서 계좌 비밀번호/인증서를 점검해주세요.',
-24005 => '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.',
-25001 => '등록된 계좌가 없습니다 (-25001).',
-25005 => '조회된 데이터가 없습니다 (-25005).',
-25006 => '계좌번호가 잘못되었습니다 (-25006).',
-25007 => '조회 기간이 잘못되었습니다 (-25007).',
];
return $messages[$errorCode] ?? '바로빌 API 오류: ' . $errorCode;
}
/**
* 은행 코드 -> 은행명 변환
*/
private function getBankName(string $code): string
{
$banks = [
'002' => 'KDB산업은행',
'003' => 'IBK기업은행',
'004' => 'KB국민은행',
'007' => '수협은행',
'011' => 'NH농협은행',
'020' => '우리은행',
'023' => 'SC제일은행',
'027' => '한국씨티은행',
'031' => '대구은행',
'032' => '부산은행',
'034' => '광주은행',
'035' => '제주은행',
'037' => '전북은행',
'039' => '경남은행',
'045' => '새마을금고',
'048' => '신협',
'050' => '저축은행',
'064' => '산림조합',
'071' => '우체국',
'081' => '하나은행',
'088' => '신한은행',
'089' => 'K뱅크',
'090' => '카카오뱅크',
'092' => '토스뱅크'
];
return $banks[$code] ?? $code;
}
/**
* 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()
];
}
}
}

View File

@@ -584,11 +584,11 @@ protected function seedMainMenus(): void
]);
$this->createMenu([
'parent_id' => $barobillGroup->id,
'name' => '계좌조회',
'url' => '/barobill/bank-account',
'name' => '계좌 입출금내역',
'url' => '/barobill/eaccount',
'icon' => 'credit-card',
'sort_order' => $barobillSubOrder++,
'options' => ['route_name' => 'barobill.bank-account.index', 'section' => 'main'],
'options' => ['route_name' => 'barobill.eaccount.index', 'section' => 'main'],
]);
$this->createMenu([
'parent_id' => $barobillGroup->id,

View File

@@ -0,0 +1,476 @@
@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, #059669, #0d9488); 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="eaccount-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 } = React;
// API Routes
const API = {
accounts: '{{ route("barobill.eaccount.accounts") }}',
transactions: '{{ route("barobill.eaccount.transactions") }}',
};
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]
};
};
// StatCard Component
const StatCard = ({ title, value, subtext, icon, color = 'blue' }) => {
const colorClasses = {
blue: 'bg-blue-50 text-blue-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.blue}`}>
{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>
);
};
// AccountSelector Component
const AccountSelector = ({ accounts, selectedAccount, onSelect }) => (
<div className="flex flex-wrap gap-2">
<button
onClick={() => onSelect('')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedAccount === ''
? 'bg-emerald-600 text-white'
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
}`}
>
전체 계좌
</button>
{accounts.map(acc => (
<button
key={acc.bankAccountNum}
onClick={() => onSelect(acc.bankAccountNum)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedAccount === acc.bankAccountNum
? 'bg-emerald-600 text-white'
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
}`}
>
{acc.bankName} {acc.bankAccountNum ? '****' + acc.bankAccountNum.slice(-4) : ''}
{acc.accountName && ` (${acc.accountName})`}
</button>
))}
</div>
);
// TransactionTable Component
const TransactionTable = ({ logs, loading, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount }) => {
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-emerald-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-emerald-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-emerald-500 outline-none"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={onThisMonth}
className="px-3 py-1.5 text-sm bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-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>
<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 text-blue-600">입금</th>
<th className="px-4 py-4 text-right bg-stone-50 text-red-600">출금</th>
<th className="px-4 py-4 text-right bg-stone-50">잔액</th>
<th className="px-4 py-4 bg-stone-50">상대방</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{logs.length === 0 ? (
<tr>
<td colSpan="7" 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">
<td className="px-4 py-3 whitespace-nowrap">
<div className="font-medium text-stone-900">{log.transDateTime || '-'}</div>
</td>
<td className="px-4 py-3">
<div className="font-medium text-stone-900">{log.bankName}</div>
<div className="text-xs text-stone-400 font-mono">
{log.bankAccountNum ? '****' + log.bankAccountNum.slice(-4) : '-'}
</div>
</td>
<td className="px-4 py-3">
<div className="font-medium text-stone-900">{log.summary || '-'}</div>
{log.memo && <div className="text-xs text-stone-400">{log.memo}</div>}
</td>
<td className="px-4 py-3 text-right font-medium text-blue-600">
{log.deposit > 0 ? log.depositFormatted + '원' : '-'}
</td>
<td className="px-4 py-3 text-right font-medium text-red-600">
{log.withdraw > 0 ? log.withdrawFormatted + '원' : '-'}
</td>
<td className="px-4 py-3 text-right text-stone-700">
{log.balanceFormatted}
</td>
<td className="px-4 py-3 text-stone-500">
{log.cast || '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
// Main App Component
const App = () => {
const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState([]);
const [selectedAccount, setSelectedAccount] = useState('');
const [logs, setLogs] = useState([]);
const [summary, setSummary] = useState({});
const [pagination, setPagination] = useState({});
const [error, setError] = useState(null);
// 날짜 필터 상태 (기본: 현재 월)
const currentMonth = getMonthDates(0);
const [dateFrom, setDateFrom] = useState(currentMonth.from);
const [dateTo, setDateTo] = useState(currentMonth.to);
// 초기 로드
useEffect(() => {
loadAccounts();
}, []);
// 날짜 또는 계좌 변경 시 거래내역 로드
useEffect(() => {
if (dateFrom && dateTo) {
loadTransactions();
}
}, [dateFrom, dateTo, selectedAccount]);
const loadAccounts = async () => {
try {
const response = await fetch(API.accounts);
const data = await response.json();
if (data.success) {
setAccounts(data.accounts || []);
}
} catch (err) {
console.error('계좌 목록 로드 오류:', err);
}
};
const loadTransactions = async (page = 1) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
startDate: dateFrom.replace(/-/g, ''),
endDate: dateTo.replace(/-/g, ''),
accountNum: selectedAccount,
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 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.totalDeposit)}
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="M12 4v16m8-8H4"/></svg>}
color="blue"
/>
<StatCard
title="총 출금액"
value={formatCurrency(summary.totalWithdraw)}
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="M20 12H4"/></svg>}
color="red"
/>
<StatCard
title="등록된 계좌"
value={`${accounts.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="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="green"
/>
<StatCard
title="거래건수"
value={`${(summary.count || 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="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>
{/* Account Filter */}
{accounts.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>
<AccountSelector
accounts={accounts}
selectedAccount={selectedAccount}
onSelect={setSelectedAccount}
/>
</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('-50214') && (
<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>
<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}
/>
)}
{/* 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('eaccount-root'));
root.render(<App />);
</script>
@endpush

View File

@@ -286,6 +286,13 @@
Route::post('/send-to-nts', [\App\Http\Controllers\Barobill\EtaxController::class, 'sendToNts'])->name('send-to-nts');
Route::post('/delete', [\App\Http\Controllers\Barobill\EtaxController::class, 'delete'])->name('delete');
});
// 계좌 입출금내역 (React 페이지)
Route::prefix('eaccount')->name('eaccount.')->group(function () {
Route::get('/', [\App\Http\Controllers\Barobill\EaccountController::class, 'index'])->name('index');
Route::get('/accounts', [\App\Http\Controllers\Barobill\EaccountController::class, 'accounts'])->name('accounts');
Route::get('/transactions', [\App\Http\Controllers\Barobill\EaccountController::class, 'transactions'])->name('transactions');
});
});
/*