feat:일반전표입력 기능 구현 (컨트롤러, 모델, 뷰, 라우트, 메뉴시더)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
355
app/Http/Controllers/Finance/JournalEntryController.php
Normal file
355
app/Http/Controllers/Finance/JournalEntryController.php
Normal file
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Finance\JournalEntry;
|
||||
use App\Models\Finance\JournalEntryLine;
|
||||
use App\Models\Finance\TradingPartner;
|
||||
use App\Models\Barobill\AccountCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class JournalEntryController extends Controller
|
||||
{
|
||||
/**
|
||||
* 전표 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$query = JournalEntry::forTenant($tenantId)
|
||||
->with('lines')
|
||||
->orderByDesc('entry_date')
|
||||
->orderByDesc('entry_no');
|
||||
|
||||
if ($request->filled('start_date')) {
|
||||
$query->where('entry_date', '>=', $request->start_date);
|
||||
}
|
||||
if ($request->filled('end_date')) {
|
||||
$query->where('entry_date', '<=', $request->end_date);
|
||||
}
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('entry_no', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$entries = $query->get();
|
||||
|
||||
$data = $entries->map(function ($entry) {
|
||||
return [
|
||||
'id' => $entry->id,
|
||||
'entry_no' => $entry->entry_no,
|
||||
'entry_date' => $entry->entry_date->format('Y-m-d'),
|
||||
'description' => $entry->description,
|
||||
'total_debit' => $entry->total_debit,
|
||||
'total_credit' => $entry->total_credit,
|
||||
'status' => $entry->status,
|
||||
'created_by_name' => $entry->created_by_name,
|
||||
'lines' => $entry->lines->map(function ($line) {
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'line_no' => $line->line_no,
|
||||
'dc_type' => $line->dc_type,
|
||||
'account_code' => $line->account_code,
|
||||
'account_name' => $line->account_name,
|
||||
'trading_partner_id' => $line->trading_partner_id,
|
||||
'trading_partner_name' => $line->trading_partner_name,
|
||||
'debit_amount' => $line->debit_amount,
|
||||
'credit_amount' => $line->credit_amount,
|
||||
'description' => $line->description,
|
||||
];
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
$stats = [
|
||||
'totalCount' => $entries->count(),
|
||||
'totalDebit' => $entries->sum('total_debit'),
|
||||
'totalCredit' => $entries->sum('total_credit'),
|
||||
'draftCount' => $entries->where('status', 'draft')->count(),
|
||||
'confirmedCount' => $entries->where('status', 'confirmed')->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$entry = JournalEntry::forTenant($tenantId)
|
||||
->with('lines')
|
||||
->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $entry->id,
|
||||
'entry_no' => $entry->entry_no,
|
||||
'entry_date' => $entry->entry_date->format('Y-m-d'),
|
||||
'entry_type' => $entry->entry_type,
|
||||
'description' => $entry->description,
|
||||
'total_debit' => $entry->total_debit,
|
||||
'total_credit' => $entry->total_credit,
|
||||
'status' => $entry->status,
|
||||
'created_by_name' => $entry->created_by_name,
|
||||
'attachment_note' => $entry->attachment_note,
|
||||
'lines' => $entry->lines->map(function ($line) {
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'line_no' => $line->line_no,
|
||||
'dc_type' => $line->dc_type,
|
||||
'account_code' => $line->account_code,
|
||||
'account_name' => $line->account_name,
|
||||
'trading_partner_id' => $line->trading_partner_id,
|
||||
'trading_partner_name' => $line->trading_partner_name,
|
||||
'debit_amount' => $line->debit_amount,
|
||||
'credit_amount' => $line->credit_amount,
|
||||
'description' => $line->description,
|
||||
];
|
||||
}),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'entry_date' => 'required|date',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'attachment_note' => 'nullable|string',
|
||||
'lines' => 'required|array|min:2',
|
||||
'lines.*.dc_type' => 'required|in:debit,credit',
|
||||
'lines.*.account_code' => 'required|string|max:10',
|
||||
'lines.*.account_name' => 'required|string|max:100',
|
||||
'lines.*.trading_partner_id' => 'nullable|integer',
|
||||
'lines.*.trading_partner_name' => 'nullable|string|max:100',
|
||||
'lines.*.debit_amount' => 'required|integer|min:0',
|
||||
'lines.*.credit_amount' => 'required|integer|min:0',
|
||||
'lines.*.description' => 'nullable|string|max:300',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$lines = $request->lines;
|
||||
|
||||
$totalDebit = collect($lines)->sum('debit_amount');
|
||||
$totalCredit = collect($lines)->sum('credit_amount');
|
||||
|
||||
if ($totalDebit !== $totalCredit || $totalDebit === 0) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) {
|
||||
$entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date);
|
||||
|
||||
$entry = JournalEntry::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'entry_no' => $entryNo,
|
||||
'entry_date' => $request->entry_date,
|
||||
'description' => $request->description,
|
||||
'total_debit' => $totalDebit,
|
||||
'total_credit' => $totalCredit,
|
||||
'status' => 'draft',
|
||||
'created_by_name' => auth()->user()?->name ?? '시스템',
|
||||
'attachment_note' => $request->attachment_note,
|
||||
]);
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $entry->id,
|
||||
'line_no' => $i + 1,
|
||||
'dc_type' => $line['dc_type'],
|
||||
'account_code' => $line['account_code'],
|
||||
'account_name' => $line['account_name'],
|
||||
'trading_partner_id' => $line['trading_partner_id'] ?? null,
|
||||
'trading_partner_name' => $line['trading_partner_name'] ?? null,
|
||||
'debit_amount' => $line['debit_amount'],
|
||||
'credit_amount' => $line['credit_amount'],
|
||||
'description' => $line['description'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $entry;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '전표가 저장되었습니다.',
|
||||
'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'entry_date' => 'required|date',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'attachment_note' => 'nullable|string',
|
||||
'lines' => 'required|array|min:2',
|
||||
'lines.*.dc_type' => 'required|in:debit,credit',
|
||||
'lines.*.account_code' => 'required|string|max:10',
|
||||
'lines.*.account_name' => 'required|string|max:100',
|
||||
'lines.*.trading_partner_id' => 'nullable|integer',
|
||||
'lines.*.trading_partner_name' => 'nullable|string|max:100',
|
||||
'lines.*.debit_amount' => 'required|integer|min:0',
|
||||
'lines.*.credit_amount' => 'required|integer|min:0',
|
||||
'lines.*.description' => 'nullable|string|max:300',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$lines = $request->lines;
|
||||
|
||||
$totalDebit = collect($lines)->sum('debit_amount');
|
||||
$totalCredit = collect($lines)->sum('credit_amount');
|
||||
|
||||
if ($totalDebit !== $totalCredit || $totalDebit === 0) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $id, $request, $lines, $totalDebit, $totalCredit) {
|
||||
$entry = JournalEntry::forTenant($tenantId)->findOrFail($id);
|
||||
|
||||
$entry->update([
|
||||
'entry_date' => $request->entry_date,
|
||||
'description' => $request->description,
|
||||
'total_debit' => $totalDebit,
|
||||
'total_credit' => $totalCredit,
|
||||
'attachment_note' => $request->attachment_note,
|
||||
]);
|
||||
|
||||
// 기존 lines 삭제 후 재생성
|
||||
$entry->lines()->delete();
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $entry->id,
|
||||
'line_no' => $i + 1,
|
||||
'dc_type' => $line['dc_type'],
|
||||
'account_code' => $line['account_code'],
|
||||
'account_name' => $line['account_name'],
|
||||
'trading_partner_id' => $line['trading_partner_id'] ?? null,
|
||||
'trading_partner_name' => $line['trading_partner_name'] ?? null,
|
||||
'debit_amount' => $line['debit_amount'],
|
||||
'credit_amount' => $line['credit_amount'],
|
||||
'description' => $line['description'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '전표가 수정되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 삭제 (soft delete)
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$entry = JournalEntry::forTenant($tenantId)->findOrFail($id);
|
||||
$entry->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '전표가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 전표번호 미리보기
|
||||
*/
|
||||
public function nextEntryNo(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$date = $request->get('date', date('Y-m-d'));
|
||||
$entryNo = JournalEntry::generateEntryNo($tenantId, $date);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'entry_no' => $entryNo,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 목록
|
||||
*/
|
||||
public function accountCodes(): JsonResponse
|
||||
{
|
||||
$codes = AccountCode::getActive();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $codes->map(function ($code) {
|
||||
return [
|
||||
'code' => $code->code,
|
||||
'name' => $code->name,
|
||||
'category' => $code->category,
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 목록
|
||||
*/
|
||||
public function tradingPartners(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$query = TradingPartner::forTenant($tenantId)->active();
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('biz_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$partners = $query->orderBy('name')->limit(50)->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $partners->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'biz_no' => $p->biz_no,
|
||||
'type' => $p->type,
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Models/Finance/JournalEntry.php
Normal file
66
app/Models/Finance/JournalEntry.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Finance;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class JournalEntry extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'journal_entries';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'entry_no',
|
||||
'entry_date',
|
||||
'entry_type',
|
||||
'description',
|
||||
'total_debit',
|
||||
'total_credit',
|
||||
'status',
|
||||
'created_by_name',
|
||||
'attachment_note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'entry_date' => 'date',
|
||||
'total_debit' => 'integer',
|
||||
'total_credit' => 'integer',
|
||||
];
|
||||
|
||||
public function lines()
|
||||
{
|
||||
return $this->hasMany(JournalEntryLine::class)->orderBy('line_no');
|
||||
}
|
||||
|
||||
public function scopeForTenant($query, $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표번호 자동채번: JE-YYYYMMDD-NNN
|
||||
*/
|
||||
public static function generateEntryNo($tenantId, $date)
|
||||
{
|
||||
$dateStr = date('Ymd', strtotime($date));
|
||||
$prefix = "JE-{$dateStr}-";
|
||||
|
||||
$last = static::where('tenant_id', $tenantId)
|
||||
->where('entry_no', 'like', $prefix . '%')
|
||||
->lockForUpdate()
|
||||
->orderByDesc('entry_no')
|
||||
->value('entry_no');
|
||||
|
||||
if ($last) {
|
||||
$seq = (int) substr($last, -3) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
40
app/Models/Finance/JournalEntryLine.php
Normal file
40
app/Models/Finance/JournalEntryLine.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Finance;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class JournalEntryLine extends Model
|
||||
{
|
||||
protected $table = 'journal_entry_lines';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'journal_entry_id',
|
||||
'line_no',
|
||||
'dc_type',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'trading_partner_id',
|
||||
'trading_partner_name',
|
||||
'debit_amount',
|
||||
'credit_amount',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'debit_amount' => 'integer',
|
||||
'credit_amount' => 'integer',
|
||||
'line_no' => 'integer',
|
||||
];
|
||||
|
||||
public function journalEntry()
|
||||
{
|
||||
return $this->belongsTo(JournalEntry::class);
|
||||
}
|
||||
|
||||
public function scopeForTenant($query, $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
}
|
||||
74
database/seeders/JournalEntryMenuSeeder.php
Normal file
74
database/seeders/JournalEntryMenuSeeder.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class JournalEntryMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = 1;
|
||||
|
||||
// 일일자금일보 메뉴 찾기
|
||||
$dailyFundMenu = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', '일일자금일보')
|
||||
->first();
|
||||
|
||||
if (!$dailyFundMenu) {
|
||||
$this->command->error('일일자금일보 메뉴를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
$parentId = $dailyFundMenu->parent_id;
|
||||
|
||||
// 일반전표입력 메뉴가 이미 있는지 확인
|
||||
$existingMenu = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', '일반전표입력')
|
||||
->where('parent_id', $parentId)
|
||||
->first();
|
||||
|
||||
if ($existingMenu) {
|
||||
$this->command->info('일반전표입력 메뉴가 이미 존재합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 일일자금일보 바로 다음 sort_order로 추가
|
||||
$newSortOrder = $dailyFundMenu->sort_order + 1;
|
||||
|
||||
// 기존 메뉴들의 sort_order를 밀어내기
|
||||
Menu::withoutGlobalScopes()
|
||||
->where('parent_id', $parentId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('sort_order', '>=', $newSortOrder)
|
||||
->increment('sort_order');
|
||||
|
||||
$menu = Menu::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'name' => '일반전표입력',
|
||||
'url' => '/finance/journal-entries',
|
||||
'icon' => 'file-text',
|
||||
'sort_order' => $newSortOrder,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->command->info("일반전표입력 메뉴 생성: {$menu->url} (sort_order: {$newSortOrder})");
|
||||
|
||||
// 결과 출력
|
||||
$this->command->info('');
|
||||
$this->command->info('=== 같은 그룹 하위 메뉴 ===');
|
||||
$children = Menu::withoutGlobalScopes()
|
||||
->where('parent_id', $parentId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('sort_order')
|
||||
->get(['name', 'url', 'sort_order']);
|
||||
|
||||
foreach ($children as $child) {
|
||||
$this->command->info("{$child->sort_order}. {$child->name} ({$child->url})");
|
||||
}
|
||||
}
|
||||
}
|
||||
817
resources/views/finance/journal-entries.blade.php
Normal file
817
resources/views/finance/journal-entries.blade.php
Normal file
@@ -0,0 +1,817 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '일반전표입력')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
@media print { .no-print { display: none !important; } }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="journal-entries-root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
|
||||
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useRef, useEffect, useCallback } = React;
|
||||
|
||||
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (ref.current && lucide.icons[name]) {
|
||||
ref.current.innerHTML = '';
|
||||
const svg = lucide.createElement(lucide.icons[name]);
|
||||
svg.setAttribute('class', className);
|
||||
ref.current.appendChild(svg);
|
||||
}
|
||||
}, [className]);
|
||||
return <span ref={ref} className="inline-flex items-center" {...props} />;
|
||||
};
|
||||
|
||||
const Plus = createIcon('plus');
|
||||
const Search = createIcon('search');
|
||||
const FileText = createIcon('file-text');
|
||||
const Trash2 = createIcon('trash-2');
|
||||
const Save = createIcon('save');
|
||||
const ArrowLeft = createIcon('arrow-left');
|
||||
const Calendar = createIcon('calendar');
|
||||
const X = createIcon('x');
|
||||
const Edit = createIcon('edit');
|
||||
const CheckCircle = createIcon('check-circle');
|
||||
const AlertTriangle = createIcon('alert-triangle');
|
||||
const ChevronDown = createIcon('chevron-down');
|
||||
|
||||
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const formatCurrency = (num) => num ? Number(num).toLocaleString() : '0';
|
||||
const formatInputCurrency = (value) => {
|
||||
if (!value && value !== 0) return '';
|
||||
const num = String(value).replace(/[^\d]/g, '');
|
||||
return num ? Number(num).toLocaleString() : '';
|
||||
};
|
||||
const parseInputCurrency = (value) => {
|
||||
const num = String(value).replace(/[^\d]/g, '');
|
||||
return num ? parseInt(num, 10) : 0;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// AccountCodeSelect (emerald 테마, 검색 가능한 드롭다운)
|
||||
// ============================================================
|
||||
const AccountCodeSelect = ({ value, onChange, accountCodes }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const containerRef = useRef(null);
|
||||
const listRef = 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 s = search.toLowerCase();
|
||||
return code.code.toLowerCase().includes(s) || code.name.toLowerCase().includes(s);
|
||||
});
|
||||
|
||||
useEffect(() => { setHighlightIndex(-1); }, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (code) => {
|
||||
onChange(code.code, code.name);
|
||||
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
||||
};
|
||||
const handleClear = (e) => { e.stopPropagation(); onChange('', ''); setSearch(''); };
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
const maxIndex = Math.min(filteredCodes.length, 50) - 1;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const ni = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
||||
setHighlightIndex(ni);
|
||||
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const ni = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
||||
setHighlightIndex(ni);
|
||||
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
||||
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredCodes[highlightIndex >= 0 ? highlightIndex : 0]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full px-2 py-1.5 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-emerald-500 ring-2 ring-emerald-500' : 'border-stone-200'} bg-white`}>
|
||||
<span className={displayText ? 'text-stone-900 truncate' : 'text-stone-400'}>{displayText || '계정과목 선택'}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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-56 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)} onKeyDown={handleKeyDown}
|
||||
placeholder="코드 또는 이름 검색..." className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-emerald-500 outline-none" autoFocus />
|
||||
</div>
|
||||
<div ref={listRef} 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, index) => (
|
||||
<div key={code.code} onClick={() => handleSelect(code)}
|
||||
className={`px-3 py-1.5 text-xs cursor-pointer ${index === highlightIndex ? 'bg-emerald-600 text-white font-semibold' : value === code.code ? 'bg-emerald-100 text-emerald-700' : 'text-stone-700 hover:bg-emerald-50'}`}>
|
||||
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-emerald-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>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// TradingPartnerSelect (동일 패턴)
|
||||
// ============================================================
|
||||
const TradingPartnerSelect = ({ value, valueName, onChange, tradingPartners }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const containerRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
const displayText = valueName || '';
|
||||
|
||||
const filteredPartners = tradingPartners.filter(p => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return p.name.toLowerCase().includes(s) || (p.biz_no && p.biz_no.includes(search));
|
||||
});
|
||||
|
||||
useEffect(() => { setHighlightIndex(-1); }, [search]);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (partner) => {
|
||||
onChange(partner.id, partner.name);
|
||||
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
||||
};
|
||||
const handleClear = (e) => { e.stopPropagation(); onChange(null, ''); setSearch(''); };
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
const maxIndex = Math.min(filteredPartners.length, 50) - 1;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const ni = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
||||
setHighlightIndex(ni);
|
||||
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const ni = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
||||
setHighlightIndex(ni);
|
||||
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
||||
} else if (e.key === 'Enter' && filteredPartners.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredPartners[highlightIndex >= 0 ? highlightIndex : 0]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full px-2 py-1.5 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-emerald-500 ring-2 ring-emerald-500' : 'border-stone-200'} bg-white`}>
|
||||
<span className={displayText ? 'text-stone-900 truncate' : 'text-stone-400'}>{displayText || '거래처 선택'}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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-56 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)} onKeyDown={handleKeyDown}
|
||||
placeholder="거래처명 또는 사업자번호 검색..." className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-emerald-500 outline-none" autoFocus />
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-48 overflow-y-auto">
|
||||
{filteredPartners.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 결과 없음</div>
|
||||
) : filteredPartners.slice(0, 50).map((p, index) => (
|
||||
<div key={p.id} onClick={() => handleSelect(p)}
|
||||
className={`px-3 py-1.5 text-xs cursor-pointer ${index === highlightIndex ? 'bg-emerald-600 text-white font-semibold' : value === p.id ? 'bg-emerald-100 text-emerald-700' : 'text-stone-700 hover:bg-emerald-50'}`}>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
{p.biz_no && <span className={`ml-1 ${index === highlightIndex ? 'text-emerald-100' : 'text-stone-400'}`}>({p.biz_no})</span>}
|
||||
</div>
|
||||
))}
|
||||
{filteredPartners.length > 50 && <div className="px-3 py-1 text-xs text-stone-400 text-center border-t">+{filteredPartners.length - 50}개 더 있음</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// JournalEntryList
|
||||
// ============================================================
|
||||
const JournalEntryList = ({ entries, stats, loading, onNew, onSelect, dateRange, setDateRange, searchTerm, setSearchTerm, filterStatus, setFilterStatus, onRefresh }) => {
|
||||
return (
|
||||
<div>
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg"><FileText className="w-5 h-5 text-emerald-600" /></div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500">전체 전표</p>
|
||||
<p className="text-lg font-bold text-stone-800">{stats.totalCount || 0}건</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg"><Edit className="w-5 h-5 text-blue-600" /></div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500">임시저장</p>
|
||||
<p className="text-lg font-bold text-stone-800">{stats.draftCount || 0}건</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg"><CheckCircle className="w-5 h-5 text-emerald-600" /></div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500">확정</p>
|
||||
<p className="text-lg font-bold text-stone-800">{stats.confirmedCount || 0}건</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-50 rounded-lg"><Save className="w-5 h-5 text-amber-600" /></div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500">차변 합계</p>
|
||||
<p className="text-lg font-bold text-stone-800">{formatCurrency(stats.totalDebit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
|
||||
className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
<span className="text-stone-400">~</span>
|
||||
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
|
||||
className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
</div>
|
||||
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
<option value="all">전체 상태</option>
|
||||
<option value="draft">임시저장</option>
|
||||
<option value="confirmed">확정</option>
|
||||
</select>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" />
|
||||
<input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="전표번호, 적요 검색..."
|
||||
className="w-full pl-9 pr-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
</div>
|
||||
<button onClick={onRefresh} className="px-4 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors">조회</button>
|
||||
<button onClick={onNew} className="px-4 py-1.5 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-1">
|
||||
<Plus className="w-4 h-4" /> 새 전표
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전표 목록 테이블 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-stone-400">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-emerald-200 border-t-emerald-600 rounded-full mx-auto mb-3"></div>
|
||||
로딩 중...
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-12 text-center text-stone-400">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-stone-300" />
|
||||
<p>등록된 전표가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-stone-50 border-b border-stone-200">
|
||||
<th className="px-4 py-3 text-left font-medium text-stone-600">전표번호</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-stone-600">전표일자</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-stone-600">적요</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-stone-600">차변합계</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-stone-600">대변합계</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-stone-600">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(entry => (
|
||||
<tr key={entry.id} onClick={() => onSelect(entry)} className="border-b border-stone-100 hover:bg-emerald-50 cursor-pointer transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-emerald-600 font-medium">{entry.entry_no}</td>
|
||||
<td className="px-4 py-3 text-stone-600">{entry.entry_date}</td>
|
||||
<td className="px-4 py-3 text-stone-800 max-w-[300px] truncate">{entry.description || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-blue-600">{formatCurrency(entry.total_debit)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-red-600">{formatCurrency(entry.total_credit)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${entry.status === 'confirmed' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{entry.status === 'confirmed' ? '확정' : '임시저장'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// JournalEntryForm (이카운트 스타일 분개 입력)
|
||||
// ============================================================
|
||||
const JournalEntryForm = ({ entry, accountCodes, tradingPartners, onSave, onDelete, onBack, saving }) => {
|
||||
const isEdit = !!entry;
|
||||
|
||||
const emptyLine = () => ({
|
||||
key: Date.now() + Math.random(),
|
||||
dc_type: 'debit',
|
||||
account_code: '',
|
||||
account_name: '',
|
||||
trading_partner_id: null,
|
||||
trading_partner_name: '',
|
||||
debit_amount: 0,
|
||||
credit_amount: 0,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
entry_date: entry?.entry_date || new Date().toISOString().split('T')[0],
|
||||
description: entry?.description || '',
|
||||
attachment_note: entry?.attachment_note || '',
|
||||
});
|
||||
|
||||
const [lines, setLines] = useState(
|
||||
entry?.lines?.length > 0
|
||||
? entry.lines.map(l => ({ ...l, key: l.id || Date.now() + Math.random() }))
|
||||
: [{ ...emptyLine(), dc_type: 'debit' }, { ...emptyLine(), dc_type: 'credit' }]
|
||||
);
|
||||
|
||||
const [entryNo, setEntryNo] = useState(entry?.entry_no || '');
|
||||
|
||||
// 새 전표일 때 전표번호 미리보기
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
fetch(`/finance/journal-entries/next-entry-no?date=${formData.entry_date}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.success) setEntryNo(d.entry_no); });
|
||||
}
|
||||
}, [formData.entry_date, isEdit]);
|
||||
|
||||
// 합계 계산
|
||||
const totalDebit = lines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0);
|
||||
const totalCredit = lines.reduce((sum, l) => sum + (parseInt(l.credit_amount) || 0), 0);
|
||||
const difference = totalDebit - totalCredit;
|
||||
const isBalanced = totalDebit === totalCredit && totalDebit > 0;
|
||||
|
||||
const addLine = () => setLines([...lines, emptyLine()]);
|
||||
|
||||
const removeLine = (index) => {
|
||||
if (lines.length <= 2) return;
|
||||
setLines(lines.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateLine = (index, field, value) => {
|
||||
const updated = [...lines];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
|
||||
// 구분 변경 시 반대쪽 금액 초기화
|
||||
if (field === 'dc_type') {
|
||||
if (value === 'debit') {
|
||||
updated[index].credit_amount = 0;
|
||||
} else {
|
||||
updated[index].debit_amount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
setLines(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isBalanced) return;
|
||||
|
||||
const payload = {
|
||||
entry_date: formData.entry_date,
|
||||
description: formData.description,
|
||||
attachment_note: formData.attachment_note,
|
||||
lines: lines.map((l, i) => ({
|
||||
dc_type: l.dc_type,
|
||||
account_code: l.account_code,
|
||||
account_name: l.account_name,
|
||||
trading_partner_id: l.trading_partner_id,
|
||||
trading_partner_name: l.trading_partner_name,
|
||||
debit_amount: parseInt(l.debit_amount) || 0,
|
||||
credit_amount: parseInt(l.credit_amount) || 0,
|
||||
description: l.description,
|
||||
})),
|
||||
};
|
||||
onSave(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-5 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-stone-800">{isEdit ? '전표 수정' : '새 전표 입력'}</h2>
|
||||
<button onClick={onBack} className="text-sm text-stone-500 hover:text-stone-700 flex items-center gap-1">
|
||||
<ArrowLeft className="w-4 h-4" /> 목록으로
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">전표번호</label>
|
||||
<input type="text" value={entryNo} readOnly
|
||||
className="w-full px-3 py-2 text-sm bg-stone-50 border border-stone-200 rounded-lg text-stone-500 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">전표일자 <span className="text-red-500">*</span></label>
|
||||
<input type="date" value={formData.entry_date}
|
||||
onChange={(e) => setFormData({ ...formData, entry_date: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">적요</label>
|
||||
<input type="text" value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="전표 적요를 입력하세요"
|
||||
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분개 테이블 (이카운트 스타일) */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden mb-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-stone-50 border-b border-stone-200">
|
||||
<th className="px-3 py-2.5 text-center font-medium text-stone-600 w-[80px]">구분</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[200px]">계정과목</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[180px]">거래처</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[140px]">차변</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[140px]">대변</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-stone-600">적요</th>
|
||||
<th className="px-3 py-2.5 text-center font-medium text-stone-600 w-[50px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, index) => (
|
||||
<tr key={line.key} className="border-b border-stone-100 hover:bg-stone-50">
|
||||
{/* 구분 */}
|
||||
<td className="px-3 py-2">
|
||||
<select value={line.dc_type} onChange={(e) => updateLine(index, 'dc_type', e.target.value)}
|
||||
className={`w-full px-2 py-1.5 text-xs border rounded-lg outline-none font-medium ${line.dc_type === 'debit' ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-red-50 border-red-200 text-red-700'}`}>
|
||||
<option value="debit">차변</option>
|
||||
<option value="credit">대변</option>
|
||||
</select>
|
||||
</td>
|
||||
{/* 계정과목 */}
|
||||
<td className="px-3 py-2">
|
||||
<AccountCodeSelect
|
||||
value={line.account_code}
|
||||
accountCodes={accountCodes}
|
||||
onChange={(code, name) => {
|
||||
const updated = [...lines];
|
||||
updated[index] = { ...updated[index], account_code: code, account_name: name };
|
||||
setLines(updated);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
{/* 거래처 */}
|
||||
<td className="px-3 py-2">
|
||||
<TradingPartnerSelect
|
||||
value={line.trading_partner_id}
|
||||
valueName={line.trading_partner_name}
|
||||
tradingPartners={tradingPartners}
|
||||
onChange={(id, name) => {
|
||||
const updated = [...lines];
|
||||
updated[index] = { ...updated[index], trading_partner_id: id, trading_partner_name: name };
|
||||
setLines(updated);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
{/* 차변 금액 */}
|
||||
<td className="px-3 py-2">
|
||||
<input type="text"
|
||||
value={line.dc_type === 'debit' ? formatInputCurrency(line.debit_amount) : ''}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', parseInputCurrency(e.target.value))}
|
||||
disabled={line.dc_type !== 'debit'}
|
||||
placeholder={line.dc_type === 'debit' ? '금액 입력' : ''}
|
||||
className={`w-full px-2 py-1.5 text-xs text-right border rounded-lg outline-none font-medium ${line.dc_type === 'debit' ? 'border-stone-200 focus:ring-2 focus:ring-blue-500 text-blue-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`}
|
||||
/>
|
||||
</td>
|
||||
{/* 대변 금액 */}
|
||||
<td className="px-3 py-2">
|
||||
<input type="text"
|
||||
value={line.dc_type === 'credit' ? formatInputCurrency(line.credit_amount) : ''}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', parseInputCurrency(e.target.value))}
|
||||
disabled={line.dc_type !== 'credit'}
|
||||
placeholder={line.dc_type === 'credit' ? '금액 입력' : ''}
|
||||
className={`w-full px-2 py-1.5 text-xs text-right border rounded-lg outline-none font-medium ${line.dc_type === 'credit' ? 'border-stone-200 focus:ring-2 focus:ring-red-500 text-red-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`}
|
||||
/>
|
||||
</td>
|
||||
{/* 적요 */}
|
||||
<td className="px-3 py-2">
|
||||
<input type="text" value={line.description || ''}
|
||||
onChange={(e) => updateLine(index, 'description', e.target.value)}
|
||||
placeholder="적요"
|
||||
className="w-full px-2 py-1.5 text-xs border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
{/* 삭제 */}
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button onClick={() => removeLine(index)}
|
||||
disabled={lines.length <= 2}
|
||||
className={`p-1 rounded ${lines.length <= 2 ? 'text-stone-200 cursor-not-allowed' : 'text-stone-400 hover:text-red-500 hover:bg-red-50'}`}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{/* 합계 행 */}
|
||||
<tfoot>
|
||||
<tr className="bg-stone-50 border-t-2 border-stone-300">
|
||||
<td colSpan={3} className="px-3 py-3">
|
||||
<button onClick={addLine} className="text-xs text-emerald-600 hover:text-emerald-700 font-medium flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" /> 행 추가
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<span className="text-xs text-stone-500">차변 합계</span>
|
||||
<p className="font-bold text-blue-700">{formatCurrency(totalDebit)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<span className="text-xs text-stone-500">대변 합계</span>
|
||||
<p className="font-bold text-red-700">{formatCurrency(totalCredit)}</p>
|
||||
</td>
|
||||
<td colSpan={2} className="px-3 py-3">
|
||||
{difference !== 0 ? (
|
||||
<div className="flex items-center gap-1 text-xs text-red-600 font-medium">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
차이: {formatCurrency(Math.abs(difference))}
|
||||
({difference > 0 ? '차변 초과' : '대변 초과'})
|
||||
</div>
|
||||
) : totalDebit > 0 ? (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-600 font-medium">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> 대차 균형
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 첨부메모 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-5 mb-4">
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">첨부 메모</label>
|
||||
<textarea value={formData.attachment_note}
|
||||
onChange={(e) => setFormData({ ...formData, attachment_note: e.target.value })}
|
||||
placeholder="추가 메모사항을 입력하세요"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{isEdit && (
|
||||
<button onClick={onDelete}
|
||||
className="px-4 py-2 text-sm text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-1">
|
||||
<Trash2 className="w-4 h-4" /> 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="px-4 py-2 text-sm text-stone-600 bg-stone-100 rounded-lg hover:bg-stone-200 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button onClick={handleSubmit}
|
||||
disabled={!isBalanced || saving}
|
||||
className={`px-6 py-2 text-sm font-medium rounded-lg flex items-center gap-1 transition-colors ${isBalanced && !saving ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-stone-200 text-stone-400 cursor-not-allowed'}`}>
|
||||
<Save className="w-4 h-4" /> {saving ? '저장 중...' : isEdit ? '수정' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// App (최상위)
|
||||
// ============================================================
|
||||
function App() {
|
||||
const [viewMode, setViewMode] = useState('list');
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [stats, setStats] = useState({});
|
||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||
const [accountCodes, setAccountCodes] = useState([]);
|
||||
const [tradingPartners, setTradingPartners] = useState([]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0],
|
||||
end: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
const fetchEntries = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start_date: dateRange.start,
|
||||
end_date: dateRange.end,
|
||||
status: filterStatus,
|
||||
search: searchTerm,
|
||||
});
|
||||
const res = await fetch(`/finance/journal-entries/list?${params}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setEntries(data.data);
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('전표 목록 조회 실패:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMasterData = async () => {
|
||||
try {
|
||||
const [acRes, tpRes] = await Promise.all([
|
||||
fetch('/finance/journal-entries/account-codes'),
|
||||
fetch('/finance/journal-entries/trading-partners'),
|
||||
]);
|
||||
const acData = await acRes.json();
|
||||
const tpData = await tpRes.json();
|
||||
if (acData.success) setAccountCodes(acData.data);
|
||||
if (tpData.success) setTradingPartners(tpData.data);
|
||||
} catch (err) {
|
||||
console.error('마스터 데이터 로딩 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchEntries(); fetchMasterData(); }, []);
|
||||
|
||||
const handleNew = () => {
|
||||
setSelectedEntry(null);
|
||||
setViewMode('form');
|
||||
};
|
||||
|
||||
const handleSelect = (entry) => {
|
||||
setSelectedEntry(entry);
|
||||
setViewMode('form');
|
||||
};
|
||||
|
||||
const handleSave = async (payload) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const isEdit = !!selectedEntry;
|
||||
const url = isEdit ? `/finance/journal-entries/${selectedEntry.id}` : '/finance/journal-entries/store';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
setViewMode('list');
|
||||
fetchEntries();
|
||||
} else {
|
||||
alert(data.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('저장 실패:', err);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedEntry || !confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch(`/finance/journal-entries/${selectedEntry.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
setViewMode('list');
|
||||
fetchEntries();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setViewMode('list');
|
||||
setSelectedEntry(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-stone-800">일반전표입력</h1>
|
||||
<p className="text-sm text-stone-500 mt-1">차변/대변 분개 전표를 등록하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' ? (
|
||||
<JournalEntryList
|
||||
entries={entries}
|
||||
stats={stats}
|
||||
loading={loading}
|
||||
onNew={handleNew}
|
||||
onSelect={handleSelect}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
filterStatus={filterStatus}
|
||||
setFilterStatus={setFilterStatus}
|
||||
onRefresh={fetchEntries}
|
||||
/>
|
||||
) : (
|
||||
<JournalEntryForm
|
||||
entry={selectedEntry}
|
||||
accountCodes={accountCodes}
|
||||
tradingPartners={tradingPartners}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
onBack={handleBack}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('journal-entries-root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
@@ -769,6 +769,25 @@
|
||||
Route::post('/memo', [\App\Http\Controllers\Finance\DailyFundController::class, 'saveMemo'])->name('memo');
|
||||
});
|
||||
|
||||
// 일반전표입력
|
||||
Route::get('/journal-entries', function () {
|
||||
if (request()->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.journal-entries'));
|
||||
}
|
||||
return view('finance.journal-entries');
|
||||
})->name('journal-entries');
|
||||
|
||||
Route::prefix('journal-entries')->name('journal-entries.')->group(function () {
|
||||
Route::get('/list', [\App\Http\Controllers\Finance\JournalEntryController::class, 'index'])->name('list');
|
||||
Route::get('/next-entry-no', [\App\Http\Controllers\Finance\JournalEntryController::class, 'nextEntryNo'])->name('next-entry-no');
|
||||
Route::get('/account-codes', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodes'])->name('account-codes');
|
||||
Route::get('/trading-partners', [\App\Http\Controllers\Finance\JournalEntryController::class, 'tradingPartners'])->name('trading-partners');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'show'])->name('show');
|
||||
Route::post('/store', [\App\Http\Controllers\Finance\JournalEntryController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 카드관리
|
||||
Route::get('/corporate-cards', function () {
|
||||
if (request()->header('HX-Request')) {
|
||||
|
||||
Reference in New Issue
Block a user