feat:바로빌 전자세금계산서 페이지 구현
- EtaxController 생성 (세금계산서 발행/조회/삭제/국세청 전송) - React 기반 Blade 뷰 페이지 생성 - 라우트 설정 (/barobill/etax) - 메뉴 시더 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
451
app/Http/Controllers/Barobill/EtaxController.php
Normal file
451
app/Http/Controllers/Barobill/EtaxController.php
Normal file
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Barobill;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 바로빌 전자세금계산서 컨트롤러
|
||||
*/
|
||||
class EtaxController extends Controller
|
||||
{
|
||||
/**
|
||||
* 바로빌 SOAP 설정
|
||||
*/
|
||||
private ?string $certKey = null;
|
||||
private ?string $corpNum = null;
|
||||
private bool $isTestMode = false;
|
||||
private ?string $soapUrl = null;
|
||||
private ?\SoapClient $soapClient = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$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/TI.asmx?WSDL'
|
||||
: 'https://ws.baroservice.com/TI.asmx?WSDL';
|
||||
|
||||
$this->initSoapClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* SOAP 클라이언트 초기화
|
||||
*/
|
||||
private function initSoapClient(): void
|
||||
{
|
||||
if (!empty($this->certKey) || $this->isTestMode) {
|
||||
try {
|
||||
$this->soapClient = new \SoapClient($this->soapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} 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.etax.index'));
|
||||
}
|
||||
|
||||
return view('barobill.etax.index', [
|
||||
'certKey' => $this->certKey,
|
||||
'corpNum' => $this->corpNum,
|
||||
'isTestMode' => $this->isTestMode,
|
||||
'hasSoapClient' => $this->soapClient !== null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
*/
|
||||
public function getInvoices(): JsonResponse
|
||||
{
|
||||
// 데이터 파일에서 조회 (실제 구현 시 DB 사용)
|
||||
$dataFile = storage_path('app/barobill/invoices_data.json');
|
||||
$invoices = [];
|
||||
|
||||
if (file_exists($dataFile)) {
|
||||
$data = json_decode(file_get_contents($dataFile), true);
|
||||
$invoices = $data['invoices'] ?? [];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'invoices' => $invoices,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행
|
||||
*/
|
||||
public function issue(Request $request): JsonResponse
|
||||
{
|
||||
$input = $request->all();
|
||||
|
||||
$useRealAPI = $this->soapClient !== null && ($this->isTestMode || !empty($this->certKey));
|
||||
|
||||
$debugInfo = [
|
||||
'hasSoapClient' => $this->soapClient !== null,
|
||||
'hasCertKey' => !empty($this->certKey),
|
||||
'hasCorpNum' => !empty($this->corpNum),
|
||||
'isTestMode' => $this->isTestMode,
|
||||
'willUseRealAPI' => $useRealAPI,
|
||||
];
|
||||
|
||||
if ($useRealAPI) {
|
||||
$apiResult = $this->issueTaxInvoice($input);
|
||||
|
||||
if ($apiResult['success']) {
|
||||
$mgtKey = $input['issueKey'] ?? 'MGT' . date('YmdHis') . rand(1000, 9999);
|
||||
|
||||
$newInvoice = $this->createInvoiceRecord($input, $mgtKey, $apiResult['data'] ?? null);
|
||||
$this->saveInvoice($newInvoice);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '세금계산서가 성공적으로 발행되었습니다.',
|
||||
'data' => [
|
||||
'issueKey' => $newInvoice['issueKey'],
|
||||
'mgtKey' => $mgtKey,
|
||||
'status' => 'issued',
|
||||
],
|
||||
'invoice' => $newInvoice,
|
||||
'simulation' => false,
|
||||
'debug' => $debugInfo,
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $apiResult['error'] ?? 'API 호출 실패',
|
||||
'error_code' => $apiResult['error_code'] ?? null,
|
||||
'debug' => $debugInfo,
|
||||
], 400);
|
||||
}
|
||||
} else {
|
||||
// 시뮬레이션 모드
|
||||
$issueKey = 'BARO-' . date('Y') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$newInvoice = $this->createInvoiceRecord($input, $issueKey, null);
|
||||
$this->saveInvoice($newInvoice);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '세금계산서가 성공적으로 발행되었습니다. (시뮬레이션 모드)',
|
||||
'data' => [
|
||||
'issueKey' => $issueKey,
|
||||
'status' => 'issued',
|
||||
],
|
||||
'invoice' => $newInvoice,
|
||||
'simulation' => true,
|
||||
'debug' => $debugInfo,
|
||||
'warning' => '시뮬레이션 모드입니다. 실제 바로빌 API를 호출하려면 CERTKEY를 설정하세요.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 국세청 전송
|
||||
*/
|
||||
public function sendToNts(Request $request): JsonResponse
|
||||
{
|
||||
$invoiceId = $request->input('invoiceId');
|
||||
|
||||
// 인보이스 조회
|
||||
$dataFile = storage_path('app/barobill/invoices_data.json');
|
||||
$data = json_decode(file_get_contents($dataFile), true) ?? ['invoices' => []];
|
||||
|
||||
$invoice = null;
|
||||
$invoiceIndex = null;
|
||||
foreach ($data['invoices'] as $index => $inv) {
|
||||
if ($inv['id'] === $invoiceId) {
|
||||
$invoice = $inv;
|
||||
$invoiceIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$invoice) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '세금계산서를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$useRealAPI = $this->soapClient !== null && !empty($this->certKey);
|
||||
|
||||
if ($useRealAPI && !empty($invoice['mgtKey'])) {
|
||||
$result = $this->callBarobillSOAP('SendToNTS', [
|
||||
'CorpNum' => $this->corpNum,
|
||||
'MgtKey' => $invoice['mgtKey'],
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
$data['invoices'][$invoiceIndex]['status'] = 'sent';
|
||||
$data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-' . date('YmdHis');
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '국세청 전송이 완료되었습니다.',
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? '전송 실패',
|
||||
], 400);
|
||||
}
|
||||
} else {
|
||||
// 시뮬레이션
|
||||
$data['invoices'][$invoiceIndex]['status'] = 'sent';
|
||||
$data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-SIM-' . date('YmdHis');
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '국세청 전송이 완료되었습니다. (시뮬레이션)',
|
||||
'simulation' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
*/
|
||||
public function delete(Request $request): JsonResponse
|
||||
{
|
||||
$invoiceId = $request->input('invoiceId');
|
||||
|
||||
$dataFile = storage_path('app/barobill/invoices_data.json');
|
||||
|
||||
if (!file_exists($dataFile)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '데이터 파일이 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($dataFile), true) ?? ['invoices' => []];
|
||||
|
||||
$originalCount = count($data['invoices']);
|
||||
$data['invoices'] = array_values(array_filter($data['invoices'], fn($inv) => $inv['id'] !== $invoiceId));
|
||||
|
||||
if (count($data['invoices']) === $originalCount) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '세금계산서를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '세금계산서가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 SOAP API 호출
|
||||
*/
|
||||
private function callBarobillSOAP(string $method, array $params = []): array
|
||||
{
|
||||
if (!$this->soapClient) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다.',
|
||||
];
|
||||
}
|
||||
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $this->certKey;
|
||||
}
|
||||
|
||||
try {
|
||||
$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' => '바로빌 API 오류 코드: ' . $resultData,
|
||||
'error_code' => $resultData,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
];
|
||||
} catch (\SoapFault $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행 API 호출
|
||||
*/
|
||||
private function issueTaxInvoice(array $invoiceData): array
|
||||
{
|
||||
$mgtKey = $invoiceData['issueKey'] ?? 'MGT' . date('YmdHis') . rand(1000, 9999);
|
||||
|
||||
$supplyAmt = 0;
|
||||
$vat = 0;
|
||||
foreach ($invoiceData['items'] ?? [] as $item) {
|
||||
$supplyAmt += floatval($item['supplyAmt'] ?? 0);
|
||||
$vat += floatval($item['vat'] ?? 0);
|
||||
}
|
||||
$total = $supplyAmt + $vat;
|
||||
|
||||
$taxType = $vat == 0 ? 2 : 1;
|
||||
|
||||
$taxInvoice = [
|
||||
'IssueDirection' => 1,
|
||||
'TaxInvoiceType' => 1,
|
||||
'ModifyCode' => '',
|
||||
'TaxType' => $taxType,
|
||||
'TaxCalcType' => 1,
|
||||
'PurposeType' => 2,
|
||||
'WriteDate' => date('Ymd', strtotime($invoiceData['supplyDate'] ?? date('Y-m-d'))),
|
||||
'AmountTotal' => number_format($supplyAmt, 0, '', ''),
|
||||
'TaxTotal' => number_format($vat, 0, '', ''),
|
||||
'TotalAmount' => number_format($total, 0, '', ''),
|
||||
'Cash' => '0',
|
||||
'ChkBill' => '0',
|
||||
'Note' => '0',
|
||||
'Credit' => number_format($total, 0, '', ''),
|
||||
'Remark1' => $invoiceData['memo'] ?? '',
|
||||
'InvoicerParty' => [
|
||||
'MgtNum' => $mgtKey,
|
||||
'CorpNum' => $this->corpNum,
|
||||
'CorpName' => $invoiceData['supplierName'] ?? '',
|
||||
'CEOName' => $invoiceData['supplierCeo'] ?? '',
|
||||
'Addr' => $invoiceData['supplierAddr'] ?? '',
|
||||
'ContactName' => $invoiceData['supplierContact'] ?? '',
|
||||
'Email' => $invoiceData['supplierEmail'] ?? '',
|
||||
],
|
||||
'InvoiceeParty' => [
|
||||
'CorpNum' => str_replace('-', '', $invoiceData['recipientBizno'] ?? ''),
|
||||
'CorpName' => $invoiceData['recipientName'] ?? '',
|
||||
'CEOName' => $invoiceData['recipientCeo'] ?? '',
|
||||
'Addr' => $invoiceData['recipientAddr'] ?? '',
|
||||
'ContactName' => $invoiceData['recipientContact'] ?? '',
|
||||
'Email' => $invoiceData['recipientEmail'] ?? '',
|
||||
],
|
||||
'TaxInvoiceTradeLineItems' => ['TaxInvoiceTradeLineItem' => []],
|
||||
];
|
||||
|
||||
foreach ($invoiceData['items'] ?? [] as $item) {
|
||||
$taxInvoice['TaxInvoiceTradeLineItems']['TaxInvoiceTradeLineItem'][] = [
|
||||
'Name' => $item['name'] ?? '',
|
||||
'ChargeableUnit' => $item['qty'] ?? '1',
|
||||
'UnitPrice' => number_format(floatval($item['unitPrice'] ?? 0), 0, '', ''),
|
||||
'Amount' => number_format(floatval($item['supplyAmt'] ?? 0), 0, '', ''),
|
||||
'Tax' => number_format(floatval($item['vat'] ?? 0), 0, '', ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->callBarobillSOAP('RegistAndIssueTaxInvoice', [
|
||||
'CorpNum' => $this->corpNum,
|
||||
'Invoice' => $taxInvoice,
|
||||
'SendSMS' => false,
|
||||
'ForceIssue' => false,
|
||||
'MailTitle' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인보이스 레코드 생성
|
||||
*/
|
||||
private function createInvoiceRecord(array $input, string $issueKey, $apiData): array
|
||||
{
|
||||
return [
|
||||
'id' => 'inv_' . time() . '_' . rand(1000, 9999),
|
||||
'issueKey' => $issueKey,
|
||||
'mgtKey' => $issueKey,
|
||||
'supplierBizno' => $input['supplierBizno'] ?? '',
|
||||
'supplierName' => $input['supplierName'] ?? '',
|
||||
'supplierCeo' => $input['supplierCeo'] ?? '',
|
||||
'supplierAddr' => $input['supplierAddr'] ?? '',
|
||||
'supplierContact' => $input['supplierContact'] ?? '',
|
||||
'supplierEmail' => $input['supplierEmail'] ?? '',
|
||||
'recipientBizno' => $input['recipientBizno'] ?? '',
|
||||
'recipientName' => $input['recipientName'] ?? '',
|
||||
'recipientCeo' => $input['recipientCeo'] ?? '',
|
||||
'recipientAddr' => $input['recipientAddr'] ?? '',
|
||||
'recipientContact' => $input['recipientContact'] ?? '',
|
||||
'recipientEmail' => $input['recipientEmail'] ?? '',
|
||||
'supplyDate' => $input['supplyDate'] ?? date('Y-m-d'),
|
||||
'items' => $input['items'] ?? [],
|
||||
'totalSupplyAmt' => $input['totalSupplyAmt'] ?? 0,
|
||||
'totalVat' => $input['totalVat'] ?? 0,
|
||||
'total' => $input['total'] ?? 0,
|
||||
'status' => 'issued',
|
||||
'memo' => $input['memo'] ?? '',
|
||||
'createdAt' => date('Y-m-d\TH:i:s'),
|
||||
'barobillInvoiceId' => is_numeric($apiData) ? (string)$apiData : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 인보이스 저장
|
||||
*/
|
||||
private function saveInvoice(array $invoice): bool
|
||||
{
|
||||
$dataDir = storage_path('app/barobill');
|
||||
if (!is_dir($dataDir)) {
|
||||
mkdir($dataDir, 0755, true);
|
||||
}
|
||||
|
||||
$dataFile = $dataDir . '/invoices_data.json';
|
||||
$existingData = ['invoices' => []];
|
||||
|
||||
if (file_exists($dataFile)) {
|
||||
$content = file_get_contents($dataFile);
|
||||
if ($content) {
|
||||
$existingData = json_decode($content, true) ?? ['invoices' => []];
|
||||
}
|
||||
}
|
||||
|
||||
$existingData['invoices'][] = $invoice;
|
||||
|
||||
if (count($existingData['invoices']) > 100) {
|
||||
$existingData['invoices'] = array_slice($existingData['invoices'], -100);
|
||||
}
|
||||
|
||||
return file_put_contents($dataFile, json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false;
|
||||
}
|
||||
}
|
||||
@@ -577,10 +577,10 @@ protected function seedMainMenus(): void
|
||||
$this->createMenu([
|
||||
'parent_id' => $barobillGroup->id,
|
||||
'name' => '전자세금계산서',
|
||||
'url' => '/barobill/tax-invoice',
|
||||
'url' => '/barobill/etax',
|
||||
'icon' => 'document-text',
|
||||
'sort_order' => $barobillSubOrder++,
|
||||
'options' => ['route_name' => 'barobill.tax-invoice.index', 'section' => 'main'],
|
||||
'options' => ['route_name' => 'barobill.etax.index', 'section' => 'main'],
|
||||
]);
|
||||
$this->createMenu([
|
||||
'parent_id' => $barobillGroup->id,
|
||||
|
||||
600
resources/views/barobill/etax/index.blade.php
Normal file
600
resources/views/barobill/etax/index.blade.php
Normal file
@@ -0,0 +1,600 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '전자세금계산서')
|
||||
|
||||
@section('content')
|
||||
<div id="etax-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 src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
// API Routes
|
||||
const API = {
|
||||
invoices: '{{ route("barobill.etax.invoices") }}',
|
||||
issue: '{{ route("barobill.etax.issue") }}',
|
||||
sendToNts: '{{ route("barobill.etax.send-to-nts") }}',
|
||||
delete: '{{ route("barobill.etax.delete") }}',
|
||||
};
|
||||
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
|
||||
// 수취인 사업자 정보 목록
|
||||
const RECIPIENT_COMPANIES = [
|
||||
{ bizno: '311-46-00378', name: '김인태', ceo: '김인태', addr: '인천광역시 부평구 안남로 272, 107동 1704호', contact: '', email: 'test@example.com' },
|
||||
{ bizno: '107-81-78114', name: '(주)이상네트웍스', ceo: '조원표', addr: '서울특별시 마포구 월드컵북로58길 9', contact: '송덕화 매니져', email: 'test@example.com' },
|
||||
{ bizno: '843-22-01859', name: '조은지게차', ceo: '유영주', addr: '경기도 김포시 사우중로 5(사우동)', contact: '', email: 'test@example.com' },
|
||||
{ bizno: '406-05-25709', name: '스카이익스프레스', ceo: '안옥현', addr: '인천광역시 연수구 능허대로79번길 65', contact: '', email: 'test@example.com' }
|
||||
];
|
||||
|
||||
// 공급자 고정 정보
|
||||
const FIXED_SUPPLIER = {
|
||||
bizno: '664-86-03713',
|
||||
name: '(주)코드브릿지엑스',
|
||||
ceo: '이의찬',
|
||||
addr: '서울 강서구 양천로 583 (염창동, 우림블루나인비즈니스센터)B동 1602호',
|
||||
contact: '전진선',
|
||||
email: 'admin@codebridge-x.com'
|
||||
};
|
||||
|
||||
// StatCard Component
|
||||
const StatCard = ({ title, value, subtext, icon }) => (
|
||||
<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 bg-blue-50 rounded-lg text-blue-600">
|
||||
{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>
|
||||
);
|
||||
|
||||
// IssueForm Component
|
||||
const IssueForm = ({ onIssue, onCancel }) => {
|
||||
const generateRandomData = () => {
|
||||
const randomRecipient = RECIPIENT_COMPANIES[Math.floor(Math.random() * RECIPIENT_COMPANIES.length)];
|
||||
const itemNames = ['시멘트 50kg', '철근 10mm', '타일 30x30', '도배지', '접착제', '페인트 18L', '유리 5mm', '목재 합판', '단열재', '방수재'];
|
||||
const itemCount = Math.floor(Math.random() * 3) + 1;
|
||||
const items = [];
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const itemName = itemNames[Math.floor(Math.random() * itemNames.length)];
|
||||
const qty = Math.floor(Math.random() * 100) + 1;
|
||||
const unitPrice = Math.floor(Math.random() * 499000) + 1000;
|
||||
items.push({ name: itemName, qty, unitPrice, vatType: 'vat' });
|
||||
}
|
||||
const randomDaysAgo = Math.floor(Math.random() * 30);
|
||||
const supplyDate = new Date();
|
||||
supplyDate.setDate(supplyDate.getDate() - randomDaysAgo);
|
||||
|
||||
return {
|
||||
supplierBizno: FIXED_SUPPLIER.bizno,
|
||||
supplierName: FIXED_SUPPLIER.name,
|
||||
supplierCeo: FIXED_SUPPLIER.ceo,
|
||||
supplierAddr: FIXED_SUPPLIER.addr,
|
||||
supplierContact: FIXED_SUPPLIER.contact,
|
||||
supplierEmail: FIXED_SUPPLIER.email,
|
||||
recipientBizno: randomRecipient.bizno,
|
||||
recipientName: randomRecipient.name,
|
||||
recipientCeo: randomRecipient.ceo,
|
||||
recipientAddr: randomRecipient.addr,
|
||||
recipientContact: randomRecipient.contact || '홍길동',
|
||||
recipientEmail: randomRecipient.email,
|
||||
supplyDate: supplyDate.toISOString().split('T')[0],
|
||||
items,
|
||||
memo: ''
|
||||
};
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(generateRandomData());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleAddItem = () => {
|
||||
setFormData({ ...formData, items: [...formData.items, { name: '', qty: 1, unitPrice: 0, vatType: 'vat' }] });
|
||||
};
|
||||
|
||||
const handleItemChange = (index, field, value) => {
|
||||
const newItems = [...formData.items];
|
||||
newItems[index][field] = value;
|
||||
setFormData({ ...formData, items: newItems });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const items = formData.items.map(item => {
|
||||
const supplyAmt = item.qty * item.unitPrice;
|
||||
const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0;
|
||||
return { ...item, supplyAmt, vat, total: supplyAmt + vat };
|
||||
});
|
||||
|
||||
const invoiceData = {
|
||||
...formData,
|
||||
items,
|
||||
totalSupplyAmt: items.reduce((sum, item) => sum + item.supplyAmt, 0),
|
||||
totalVat: items.reduce((sum, item) => sum + item.vat, 0),
|
||||
total: items.reduce((sum, item) => sum + item.total, 0)
|
||||
};
|
||||
|
||||
await onIssue(invoiceData);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const regenerateData = () => {
|
||||
setFormData(generateRandomData());
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 사업자번호</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierBizno} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 상호</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierName} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 대표자명</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierCeo} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급자 주소</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-stone-50" value={formData.supplierAddr} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">수취자 사업자번호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientBizno} onChange={(e) => setFormData({ ...formData, recipientBizno: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">수취자 상호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientName} onChange={(e) => setFormData({ ...formData, recipientName: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">수취자 대표자명</label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientCeo} onChange={(e) => setFormData({ ...formData, recipientCeo: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">수취자 주소 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientAddr} onChange={(e) => setFormData({ ...formData, recipientAddr: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">수취자 이메일 <span className="text-red-500">*</span></label>
|
||||
<input type="email" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.recipientEmail} onChange={(e) => setFormData({ ...formData, recipientEmail: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">공급일자</label>
|
||||
<input type="date" className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" value={formData.supplyDate} onChange={(e) => setFormData({ ...formData, supplyDate: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-sm font-medium text-stone-700">품목 정보</label>
|
||||
<button type="button" onClick={handleAddItem} className="text-sm text-blue-600 hover:text-blue-700 font-medium">+ 품목 추가</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{formData.items.map((item, index) => (
|
||||
<div key={index} className="grid grid-cols-12 gap-2 items-end p-3 bg-stone-50 rounded-lg">
|
||||
<div className="col-span-4">
|
||||
<input type="text" className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="품목명" value={item.name} onChange={(e) => handleItemChange(index, 'name', e.target.value)} required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input type="number" className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="수량" value={item.qty} onChange={(e) => handleItemChange(index, 'qty', parseFloat(e.target.value) || 0)} min="0" step="0.01" required />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<input type="number" className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="단가" value={item.unitPrice} onChange={(e) => handleItemChange(index, 'unitPrice', parseFloat(e.target.value) || 0)} min="0" required />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<select className="w-full rounded-lg border-stone-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={item.vatType} onChange={(e) => handleItemChange(index, 'vatType', e.target.value)}>
|
||||
<option value="vat">과세</option>
|
||||
<option value="zero">영세</option>
|
||||
<option value="exempt">면세</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
{formData.items.length > 1 && (
|
||||
<button type="button" onClick={() => setFormData({ ...formData, items: formData.items.filter((_, i) => i !== index) })} className="w-full p-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg border border-red-200">
|
||||
<svg className="w-4 h-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">비고</label>
|
||||
<textarea className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" rows="2" value={formData.memo} onChange={(e) => setFormData({ ...formData, memo: e.target.value })} placeholder="추가 메모사항" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-stone-600 hover:text-stone-800">취소</button>
|
||||
<button type="button" onClick={regenerateData} className="px-4 py-2 text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
랜덤 데이터 재생성
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" disabled={isSubmitting} className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{isSubmitting ? (
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> 발행 중...</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg> 세금계산서 발행</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// InvoiceList Component
|
||||
const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete }) => {
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
||||
const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR');
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
'draft': { bg: 'bg-stone-100', text: 'text-stone-800', label: '작성중' },
|
||||
'issued': { bg: 'bg-blue-100', text: 'text-blue-800', label: '발행완료' },
|
||||
'sent': { bg: 'bg-green-100', text: 'text-green-800', label: '국세청 전송완료' },
|
||||
'cancelled': { bg: 'bg-red-100', text: 'text-red-800', label: '취소됨' }
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig['draft'];
|
||||
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>{config.label}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-100 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-stone-900">발행 내역</h2>
|
||||
<span className="text-sm text-stone-500">총 {invoices.length}건</span>
|
||||
</div>
|
||||
<div className="overflow-x-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">
|
||||
<tr>
|
||||
<th className="px-6 py-4">발행번호</th>
|
||||
<th className="px-6 py-4">수취자</th>
|
||||
<th className="px-6 py-4">공급일자</th>
|
||||
<th className="px-6 py-4">공급가액</th>
|
||||
<th className="px-6 py-4">부가세</th>
|
||||
<th className="px-6 py-4">합계</th>
|
||||
<th className="px-6 py-4">상태</th>
|
||||
<th className="px-6 py-4 text-right">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{invoices.length === 0 ? (
|
||||
<tr><td colSpan="8" className="px-6 py-8 text-center text-stone-400">발행된 세금계산서가 없습니다.</td></tr>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="hover:bg-stone-50 transition-colors cursor-pointer" onClick={() => onViewDetail(invoice)}>
|
||||
<td className="px-6 py-4 font-medium text-stone-900">{invoice.issueKey || invoice.id}</td>
|
||||
<td className="px-6 py-4">{invoice.recipientName}</td>
|
||||
<td className="px-6 py-4">{formatDate(invoice.supplyDate)}</td>
|
||||
<td className="px-6 py-4">{formatCurrency(invoice.totalSupplyAmt)}</td>
|
||||
<td className="px-6 py-4">{formatCurrency(invoice.totalVat)}</td>
|
||||
<td className="px-6 py-4 font-bold text-stone-900">{formatCurrency(invoice.total)}</td>
|
||||
<td className="px-6 py-4">{getStatusBadge(invoice.status)}</td>
|
||||
<td className="px-6 py-4 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{invoice.status === 'issued' && (
|
||||
<button onClick={() => onCheckStatus(invoice.id)} className="text-blue-600 hover:text-blue-700 text-xs font-medium px-2 py-1 rounded hover:bg-blue-50 transition-colors">전송</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(invoice.id)} className="text-red-400 hover:text-red-600 p-1.5 rounded hover:bg-red-50 transition-colors" title="삭제">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// InvoiceDetailModal Component
|
||||
const InvoiceDetailModal = ({ invoice, onClose }) => {
|
||||
if (!invoice) return null;
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
||||
const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-6 border-b border-stone-100 flex justify-between items-center bg-stone-50 shrink-0">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-stone-900">세금계산서 상세</h3>
|
||||
<p className="text-sm text-stone-500">발행번호: {invoice.issueKey || invoice.id}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-stone-200 rounded-full transition-colors">
|
||||
<svg className="w-5 h-5 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-1 block">공급자</label>
|
||||
<div className="font-medium text-stone-900">{invoice.supplierName}</div>
|
||||
<div className="text-sm text-stone-500">{invoice.supplierBizno}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-1 block">수취자</label>
|
||||
<div className="font-medium text-stone-900">{invoice.recipientName}</div>
|
||||
<div className="text-sm text-stone-500">{invoice.recipientBizno}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-1 block">공급일자</label>
|
||||
<div className="font-medium text-stone-900">{formatDate(invoice.supplyDate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-stone-500 mb-2 block">품목 내역</label>
|
||||
<div className="border border-stone-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">품목명</th>
|
||||
<th className="px-4 py-2 text-right">수량</th>
|
||||
<th className="px-4 py-2 text-right">단가</th>
|
||||
<th className="px-4 py-2 text-right">공급가액</th>
|
||||
<th className="px-4 py-2 text-right">부가세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{invoice.items?.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-4 py-2">{item.name}</td>
|
||||
<td className="px-4 py-2 text-right">{item.qty}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(item.unitPrice)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(item.supplyAmt)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(item.vat)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-stone-50 border-t-2 border-stone-200">
|
||||
<tr>
|
||||
<td colSpan="3" className="px-4 py-2 font-bold text-right">합계</td>
|
||||
<td className="px-4 py-2 font-bold text-right">{formatCurrency(invoice.totalSupplyAmt)}</td>
|
||||
<td className="px-4 py-2 font-bold text-right">{formatCurrency(invoice.totalVat)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan="4" className="px-4 py-2 font-bold text-right">총 합계</td>
|
||||
<td className="px-4 py-2 font-bold text-right text-blue-600">{formatCurrency(invoice.total)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{invoice.ntsReceiptNo && (
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="text-sm font-medium text-green-800">국세청 접수번호</div>
|
||||
<div className="text-lg font-bold text-green-900 mt-1">{invoice.ntsReceiptNo}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 border-t border-stone-100 bg-stone-50 flex justify-end shrink-0">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-white border border-stone-200 rounded-lg text-stone-700 hover:bg-stone-50 font-medium transition-colors">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ApiLogs Component
|
||||
const ApiLogs = ({ logs, onClear }) => {
|
||||
if (logs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-bold text-stone-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
API 통신 로그
|
||||
</h2>
|
||||
<button onClick={onClear} className="text-sm text-stone-500 hover:text-stone-900 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
로그 지우기
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className={`p-4 rounded-lg text-sm border ${log.type === 'error' ? 'bg-red-50 text-red-900 border-red-200' : log.type === 'response' ? 'bg-green-50 text-green-900 border-green-200' : 'bg-blue-50 text-blue-900 border-blue-200'}`}>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="font-semibold">{log.message}</div>
|
||||
<span className="text-xs opacity-75 whitespace-nowrap ml-4">{log.timestamp}</span>
|
||||
</div>
|
||||
{log.data && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs font-medium opacity-75 hover:opacity-100">데이터 보기/숨기기</summary>
|
||||
<pre className="mt-2 p-3 bg-white/50 rounded border text-xs overflow-x-auto font-mono whitespace-pre-wrap break-words">{typeof log.data === 'string' ? log.data : JSON.stringify(log.data, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main App Component
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
const [showIssueForm, setShowIssueForm] = useState(false);
|
||||
const [apiLogs, setApiLogs] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
}, []);
|
||||
|
||||
const loadInvoices = async () => {
|
||||
try {
|
||||
const response = await fetch(API.invoices);
|
||||
const data = await response.json();
|
||||
setInvoices(data.invoices || []);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to load invoices:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addApiLog = (type, message, data = null) => {
|
||||
const log = { id: Date.now() + Math.random(), type, message, data, timestamp: new Date().toLocaleTimeString('ko-KR') };
|
||||
setApiLogs(prev => [log, ...prev].slice(0, 20));
|
||||
};
|
||||
|
||||
const handleIssue = async (invoiceData) => {
|
||||
addApiLog('request', '바로빌 API 호출: 세금계산서 발행', invoiceData);
|
||||
try {
|
||||
const response = await fetch(API.issue, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify(invoiceData)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addApiLog('response', '바로빌 API 응답: 발행 완료', result);
|
||||
setShowIssueForm(false);
|
||||
await loadInvoices();
|
||||
} else {
|
||||
addApiLog('error', '바로빌 API 오류: ' + (result.error || '알 수 없는 오류'), result);
|
||||
}
|
||||
} catch (err) {
|
||||
addApiLog('error', '바로빌 API 오류: ' + err.message, { message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckStatus = async (invoiceId) => {
|
||||
addApiLog('request', '바로빌 API 호출: 국세청 전송', { invoiceId });
|
||||
try {
|
||||
const response = await fetch(API.sendToNts, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify({ invoiceId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addApiLog('response', '바로빌 API 응답: 전송 완료', result);
|
||||
await loadInvoices();
|
||||
} else {
|
||||
addApiLog('error', '바로빌 API 오류: ' + (result.error || '알 수 없는 오류'), result);
|
||||
}
|
||||
} catch (err) {
|
||||
addApiLog('error', '바로빌 API 오류: ' + err.message, { message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (invoiceId) => {
|
||||
if (!window.confirm('세금계산서를 삭제하시겠습니까?')) return;
|
||||
addApiLog('request', '세금계산서 삭제 요청', { invoiceId });
|
||||
try {
|
||||
const response = await fetch(API.delete, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify({ invoiceId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addApiLog('response', '세금계산서 삭제 완료', result);
|
||||
await loadInvoices();
|
||||
} else {
|
||||
addApiLog('error', '삭제 오류: ' + (result.error || '알 수 없는 오류'), result);
|
||||
alert('삭제에 실패했습니다: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
addApiLog('error', '삭제 오류: ' + err.message, { message: err.message });
|
||||
alert('삭제 중 오류가 발생했습니다: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: invoices.length,
|
||||
issued: invoices.filter(i => i.status === 'issued' || i.status === 'sent').length,
|
||||
sent: invoices.filter(i => i.status === 'sent').length,
|
||||
totalAmount: invoices.reduce((sum, i) => sum + (i.total || 0), 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-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={stats.total.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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>} />
|
||||
<StatCard title="발행 완료" value={stats.issued.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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>} />
|
||||
<StatCard title="국세청 전송 완료" value={stats.sent.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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>} />
|
||||
<StatCard title="총 발행 금액" value={new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(stats.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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>} />
|
||||
</div>
|
||||
|
||||
{/* Issue Form Section */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-stone-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
전자세금계산서 발행
|
||||
</h2>
|
||||
{!showIssueForm && (
|
||||
<button onClick={() => setShowIssueForm(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
새로 발행 (랜덤 데이터)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showIssueForm && <IssueForm onIssue={handleIssue} onCancel={() => setShowIssueForm(false)} />}
|
||||
</div>
|
||||
|
||||
{/* Invoice List */}
|
||||
<InvoiceList invoices={invoices} onViewDetail={setSelectedInvoice} onCheckStatus={handleCheckStatus} onDelete={handleDelete} />
|
||||
|
||||
{/* API Logs */}
|
||||
<ApiLogs logs={apiLogs} onClear={() => setApiLogs([])} />
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedInvoice && <InvoiceDetailModal invoice={selectedInvoice} onClose={() => setSelectedInvoice(null)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('etax-root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
@endpush
|
||||
@@ -277,6 +277,15 @@
|
||||
Route::get('/usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'usage'])->name('usage.index');
|
||||
// 기존 config 라우트 (호환성)
|
||||
Route::get('/config', [\App\Http\Controllers\Barobill\BarobillController::class, 'config'])->name('config.index');
|
||||
|
||||
// 전자세금계산서 (React 페이지)
|
||||
Route::prefix('etax')->name('etax.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Barobill\EtaxController::class, 'index'])->name('index');
|
||||
Route::get('/invoices', [\App\Http\Controllers\Barobill\EtaxController::class, 'getInvoices'])->name('invoices');
|
||||
Route::post('/issue', [\App\Http\Controllers\Barobill\EtaxController::class, 'issue'])->name('issue');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user