feat:일반전표입력 기능 구현 (컨트롤러, 모델, 뷰, 라우트, 메뉴시더)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-06 15:23:43 +09:00
parent ce08d0110a
commit f277ec8475
6 changed files with 1371 additions and 0 deletions

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

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

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

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

View 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

View File

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