feat:홈택스 분개 저장 구조 변경 (journal_entries → hometax_invoice_journals)
- HometaxInvoiceJournal 모델 신규 생성 - HometaxInvoice에 journals() 관계 추가 - HometaxController: 저장 로직 변경 + 조회/삭제 엔드포인트 추가 - HometaxSyncService: hasJournal 필드 추가 - 프론트엔드: 분개완료 상태 표시, 기존 분개 로드/수정/삭제 지원 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,7 @@
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Models\Barobill\CardTransaction as BarobillCardTransaction;
|
||||
use App\Models\Barobill\HometaxInvoice;
|
||||
use App\Models\Finance\JournalEntry;
|
||||
use App\Models\Finance\JournalEntryLine;
|
||||
use App\Models\Barobill\HometaxInvoiceJournal;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\Barobill\HometaxSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -1632,55 +1631,30 @@ public function createJournalEntry(Request $request): JsonResponse
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$result = DB::transaction(function () use ($tenantId, $invoice, $validated) {
|
||||
$entryNo = JournalEntry::generateEntryNo($tenantId, $invoice->write_date);
|
||||
$tradingPartner = $invoice->invoice_type === 'sales'
|
||||
? $invoice->invoicee_corp_name
|
||||
: $invoice->invoicer_corp_name;
|
||||
|
||||
$totalDebit = collect($validated['lines'])->sum('debit_amount');
|
||||
$totalCredit = collect($validated['lines'])->sum('credit_amount');
|
||||
|
||||
$tradingPartner = $invoice->invoice_type === 'sales'
|
||||
? $invoice->invoicee_corp_name
|
||||
: $invoice->invoicer_corp_name;
|
||||
|
||||
$entry = JournalEntry::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'entry_no' => $entryNo,
|
||||
'entry_date' => $invoice->write_date,
|
||||
'entry_type' => 'general',
|
||||
'description' => "[홈택스-{$invoice->nts_confirm_num}] {$tradingPartner}",
|
||||
'total_debit' => $totalDebit,
|
||||
'total_credit' => $totalCredit,
|
||||
'status' => 'confirmed',
|
||||
'created_by_name' => auth()->user()?->name ?? '시스템',
|
||||
]);
|
||||
|
||||
foreach ($validated['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_name' => $tradingPartner,
|
||||
'debit_amount' => $line['debit_amount'],
|
||||
'credit_amount' => $line['credit_amount'],
|
||||
'description' => $line['description'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
return $entry;
|
||||
DB::transaction(function () use ($tenantId, $invoice, $validated, $tradingPartner) {
|
||||
HometaxInvoiceJournal::saveJournals($tenantId, $invoice->id, [
|
||||
'nts_confirm_num' => $invoice->nts_confirm_num,
|
||||
'invoice_type' => $invoice->invoice_type,
|
||||
'write_date' => $invoice->write_date,
|
||||
'supply_amount' => $invoice->supply_amount,
|
||||
'tax_amount' => $invoice->tax_amount,
|
||||
'total_amount' => $invoice->total_amount,
|
||||
'trading_partner_name' => $tradingPartner,
|
||||
], $validated['lines']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "전표 {$result->entry_no}가 생성되었습니다.",
|
||||
'data' => $result->load('lines'),
|
||||
'message' => '분개가 저장되었습니다.',
|
||||
]);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
$firstError = collect($errors)->flatten()->first() ?? '입력 데이터가 올바르지 않습니다.';
|
||||
Log::error('분개 생성 검증 오류', ['errors' => $errors]);
|
||||
Log::error('분개 저장 검증 오류', ['errors' => $errors]);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $firstError,
|
||||
@@ -1692,10 +1666,75 @@ public function createJournalEntry(Request $request): JsonResponse
|
||||
'error' => '해당 세금계산서를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('분개 생성 오류: ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
|
||||
Log::error('분개 저장 오류: ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '분개 생성 오류: ' . $e->getMessage(),
|
||||
'error' => '분개 저장 오류: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 인보이스의 분개 조회
|
||||
*/
|
||||
public function getJournals(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$invoiceId = $request->input('invoice_id');
|
||||
|
||||
if (!$invoiceId) {
|
||||
return response()->json(['success' => false, 'error' => 'invoice_id는 필수입니다.'], 422);
|
||||
}
|
||||
|
||||
$journals = HometaxInvoiceJournal::getByInvoiceId($tenantId, (int)$invoiceId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $journals->map(function ($j) {
|
||||
return [
|
||||
'dc_type' => $j->dc_type,
|
||||
'account_code' => $j->account_code,
|
||||
'account_name' => $j->account_name,
|
||||
'debit_amount' => $j->debit_amount,
|
||||
'credit_amount' => $j->credit_amount,
|
||||
'description' => $j->description,
|
||||
];
|
||||
}),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('분개 조회 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '분개 조회 오류: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 인보이스의 분개 삭제
|
||||
*/
|
||||
public function deleteJournals(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$invoiceId = $request->input('invoice_id');
|
||||
|
||||
if (!$invoiceId) {
|
||||
return response()->json(['success' => false, 'error' => 'invoice_id는 필수입니다.'], 422);
|
||||
}
|
||||
|
||||
$deleted = HometaxInvoiceJournal::deleteJournals($tenantId, (int)$invoiceId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "분개가 삭제되었습니다. ({$deleted}건)",
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('분개 삭제 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '분개 삭제 오류: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
@@ -105,6 +106,14 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 관계
|
||||
*/
|
||||
public function journals(): HasMany
|
||||
{
|
||||
return $this->hasMany(HometaxInvoiceJournal::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출 스코프
|
||||
*/
|
||||
|
||||
125
app/Models/Barobill/HometaxInvoiceJournal.php
Normal file
125
app/Models/Barobill/HometaxInvoiceJournal.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\Tenants\Tenant;
|
||||
|
||||
/**
|
||||
* 홈택스 세금계산서 분개 모델
|
||||
* 하나의 세금계산서를 여러 계정과목으로 분개하여 저장
|
||||
*/
|
||||
class HometaxInvoiceJournal extends Model
|
||||
{
|
||||
protected $table = 'hometax_invoice_journals';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'hometax_invoice_id',
|
||||
'nts_confirm_num',
|
||||
'dc_type',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'debit_amount',
|
||||
'credit_amount',
|
||||
'description',
|
||||
'sort_order',
|
||||
'invoice_type',
|
||||
'write_date',
|
||||
'supply_amount',
|
||||
'tax_amount',
|
||||
'total_amount',
|
||||
'trading_partner_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'debit_amount' => 'integer',
|
||||
'credit_amount' => 'integer',
|
||||
'supply_amount' => 'integer',
|
||||
'tax_amount' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'write_date' => 'date',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HometaxInvoice::class, 'hometax_invoice_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 인보이스의 분개 저장 (DELETE 후 INSERT)
|
||||
*/
|
||||
public static function saveJournals(int $tenantId, int $invoiceId, array $invoiceData, array $lines): void
|
||||
{
|
||||
// 기존 분개 삭제
|
||||
self::where('tenant_id', $tenantId)
|
||||
->where('hometax_invoice_id', $invoiceId)
|
||||
->delete();
|
||||
|
||||
// 새 분개 저장
|
||||
foreach ($lines as $index => $line) {
|
||||
self::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'hometax_invoice_id' => $invoiceId,
|
||||
'nts_confirm_num' => $invoiceData['nts_confirm_num'] ?? '',
|
||||
'dc_type' => $line['dc_type'],
|
||||
'account_code' => $line['account_code'],
|
||||
'account_name' => $line['account_name'],
|
||||
'debit_amount' => (int)($line['debit_amount'] ?? 0),
|
||||
'credit_amount' => (int)($line['credit_amount'] ?? 0),
|
||||
'description' => $line['description'] ?? '',
|
||||
'sort_order' => $index,
|
||||
'invoice_type' => $invoiceData['invoice_type'] ?? '',
|
||||
'write_date' => $invoiceData['write_date'] ?? null,
|
||||
'supply_amount' => (int)($invoiceData['supply_amount'] ?? 0),
|
||||
'tax_amount' => (int)($invoiceData['tax_amount'] ?? 0),
|
||||
'total_amount' => (int)($invoiceData['total_amount'] ?? 0),
|
||||
'trading_partner_name' => $invoiceData['trading_partner_name'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 인보이스의 분개 조회
|
||||
*/
|
||||
public static function getByInvoiceId(int $tenantId, int $invoiceId): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return self::where('tenant_id', $tenantId)
|
||||
->where('hometax_invoice_id', $invoiceId)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 인보이스의 분개 삭제
|
||||
*/
|
||||
public static function deleteJournals(int $tenantId, int $invoiceId): int
|
||||
{
|
||||
return self::where('tenant_id', $tenantId)
|
||||
->where('hometax_invoice_id', $invoiceId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 완료된 인보이스 ID 목록 일괄 조회
|
||||
*/
|
||||
public static function getJournaledInvoiceIds(int $tenantId, array $invoiceIds): array
|
||||
{
|
||||
if (empty($invoiceIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::where('tenant_id', $tenantId)
|
||||
->whereIn('hometax_invoice_id', $invoiceIds)
|
||||
->distinct()
|
||||
->pluck('hometax_invoice_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Barobill;
|
||||
|
||||
use App\Models\Barobill\HometaxInvoice;
|
||||
use App\Models\Barobill\HometaxInvoiceJournal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -128,8 +129,12 @@ public function getLocalInvoices(
|
||||
|
||||
$invoices = $query->orderByDesc('write_date')->get();
|
||||
|
||||
// 분개 완료 인보이스 ID 조회
|
||||
$invoiceIds = $invoices->pluck('id')->toArray();
|
||||
$journaledIds = HometaxInvoiceJournal::getJournaledInvoiceIds($tenantId, $invoiceIds);
|
||||
|
||||
// API 응답 형식에 맞게 변환
|
||||
$formattedInvoices = $invoices->map(function ($inv) {
|
||||
$formattedInvoices = $invoices->map(function ($inv) use ($journaledIds) {
|
||||
return [
|
||||
'id' => $inv->id,
|
||||
'ntsConfirmNum' => $inv->nts_confirm_num,
|
||||
@@ -159,6 +164,7 @@ public function getLocalInvoices(
|
||||
'memo' => $inv->memo,
|
||||
'category' => $inv->category,
|
||||
'isChecked' => $inv->is_checked,
|
||||
'hasJournal' => in_array($inv->id, $journaledIds),
|
||||
'syncedAt' => $inv->synced_at?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
manualUpdate: '{{ route("barobill.hometax.manual-update", ["id" => "__ID__"]) }}',
|
||||
manualDestroy: '{{ route("barobill.hometax.manual-destroy", ["id" => "__ID__"]) }}',
|
||||
createJournalEntry: '{{ route("barobill.hometax.create-journal-entry") }}',
|
||||
journals: '{{ route("barobill.hometax.journals") }}',
|
||||
deleteJournals: '{{ route("barobill.hometax.journals.delete") }}',
|
||||
cardTransactions: '{{ route("barobill.hometax.card-transactions") }}',
|
||||
accountCodes: '{{ route("barobill.ecard.account-codes") }}',
|
||||
};
|
||||
@@ -338,9 +340,9 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => onJournalEntry && onJournalEntry(inv)}
|
||||
className="px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs hover:bg-emerald-100 transition-colors"
|
||||
title="분개 생성"
|
||||
>분개</button>
|
||||
className={`px-2 py-1 rounded text-xs transition-colors ${inv.hasJournal ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'}`}
|
||||
title={inv.hasJournal ? "분개 수정" : "분개 생성"}
|
||||
>{inv.hasJournal ? '분개완료' : '분개'}</button>
|
||||
{inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') && (
|
||||
<>
|
||||
<button
|
||||
@@ -778,7 +780,7 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
|
||||
}
|
||||
};
|
||||
|
||||
// 분개 생성
|
||||
// 분개 저장
|
||||
const handleJournalSave = async (invoiceId, lines) => {
|
||||
try {
|
||||
const res = await fetch(API.createJournalEntry, {
|
||||
@@ -791,17 +793,41 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
notify(data.message || '전표가 생성되었습니다.', 'success');
|
||||
notify(data.message || '분개가 저장되었습니다.', 'success');
|
||||
setShowJournalModal(false);
|
||||
setJournalInvoice(null);
|
||||
loadCurrentTabData();
|
||||
} else {
|
||||
const errorMsg = data.error || '분개 생성 실패';
|
||||
console.error('분개 생성 실패:', data);
|
||||
const errorMsg = data.error || '분개 저장 실패';
|
||||
console.error('분개 저장 실패:', data);
|
||||
notify(errorMsg, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('분개 생성 오류:', err);
|
||||
notify('분개 생성 오류: ' + err.message, 'error');
|
||||
console.error('분개 저장 오류:', err);
|
||||
notify('분개 저장 오류: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 분개 삭제
|
||||
const handleJournalDelete = async (invoiceId) => {
|
||||
try {
|
||||
const res = await fetch(`${API.deleteJournals}?invoice_id=${invoiceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
notify(data.message || '분개가 삭제되었습니다.', 'success');
|
||||
setShowJournalModal(false);
|
||||
setJournalInvoice(null);
|
||||
loadCurrentTabData();
|
||||
} else {
|
||||
notify(data.error || '분개 삭제 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
notify('분개 삭제 오류: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1386,6 +1412,7 @@ className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium
|
||||
isOpen={showJournalModal}
|
||||
onClose={() => { setShowJournalModal(false); setJournalInvoice(null); }}
|
||||
onSave={handleJournalSave}
|
||||
onDelete={handleJournalDelete}
|
||||
invoice={journalInvoice}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
@@ -2031,9 +2058,9 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// JournalEntryModal - 분개 생성 모달
|
||||
// JournalEntryModal - 분개 생성/수정 모달
|
||||
// ============================================
|
||||
const JournalEntryModal = ({ isOpen, onClose, onSave, invoice, accountCodes = [] }) => {
|
||||
const JournalEntryModal = ({ isOpen, onClose, onSave, onDelete, invoice, accountCodes = [] }) => {
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
|
||||
const isSales = invoice.invoiceType === 'sales' || invoice.invoice_type === 'sales';
|
||||
const supplyAmount = parseFloat(invoice.supplyAmount || invoice.supply_amount || 0);
|
||||
@@ -2059,6 +2086,25 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
|
||||
|
||||
const [lines, setLines] = useState(getDefaultLines());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingJournal, setLoadingJournal] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// 기존 분개 로드
|
||||
useEffect(() => {
|
||||
if (invoice.hasJournal) {
|
||||
setLoadingJournal(true);
|
||||
fetch(`${API.journals}?invoice_id=${invoice.id}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
setLines(data.data);
|
||||
setIsEditMode(true);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('기존 분개 로드 오류:', err))
|
||||
.finally(() => setLoadingJournal(false));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLine = (idx, field, value) => {
|
||||
setLines(prev => prev.map((l, i) => i === idx ? { ...l, [field]: value } : l));
|
||||
@@ -2082,7 +2128,6 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 계정과목 검증
|
||||
const emptyLine = lines.find(l => !l.account_code || !l.account_name);
|
||||
if (emptyLine) {
|
||||
notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning');
|
||||
@@ -2097,6 +2142,13 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('분개를 삭제하시겠습니까?')) return;
|
||||
setSaving(true);
|
||||
await onDelete(invoice.id);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const tradingPartner = isSales
|
||||
? (invoice.invoiceeCorpName || invoice.invoicee_corp_name || '')
|
||||
: (invoice.invoicerCorpName || invoice.invoicer_corp_name || '');
|
||||
@@ -2105,7 +2157,9 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-stone-900">분개 생성 (일반전표)</h3>
|
||||
<h3 className="text-lg font-bold text-stone-900">
|
||||
{isEditMode ? '분개 수정' : '분개 생성'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-stone-100 rounded-lg transition-colors">
|
||||
<svg className="w-5 h-5 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -2124,83 +2178,103 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분개 라인 테이블 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
|
||||
<table className="w-full text-sm border border-stone-200 rounded-lg overflow-hidden">
|
||||
<thead>
|
||||
<tr className="bg-stone-100">
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차/대</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200" colSpan="2">계정과목</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차변금액</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">대변금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, idx) => (
|
||||
<tr key={idx} className="border-b border-stone-100">
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDcType(idx)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity ${line.dc_type === 'debit' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}
|
||||
title="클릭하여 차변/대변 전환"
|
||||
>
|
||||
{line.dc_type === 'debit' ? '차변' : '대변'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2" colSpan="2">
|
||||
<AccountCodeSelect
|
||||
value={line.account_code}
|
||||
onChange={(code, name) => {
|
||||
setLines(prev => prev.map((l, i) => i === idx ? { ...l, account_code: code, account_name: name } : l));
|
||||
}}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(idx, 'debit_amount', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-violet-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(idx, 'credit_amount', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-violet-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
{loadingJournal ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-emerald-600 border-t-transparent"></div>
|
||||
<span className="ml-2 text-sm text-stone-500">분개 데이터 로딩중...</span>
|
||||
</div>
|
||||
) : (
|
||||
/* 분개 라인 테이블 */
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
|
||||
<table className="w-full text-sm border border-stone-200 rounded-lg overflow-hidden">
|
||||
<thead>
|
||||
<tr className="bg-stone-100">
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차/대</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200" colSpan="2">계정과목</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차변금액</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">대변금액</th>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 */}
|
||||
<tr className={`font-bold ${isBalanced ? 'bg-green-50' : 'bg-red-50'}`}>
|
||||
<td colSpan="3" className="px-3 py-2 text-center text-sm">합계</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalDebit)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalCredit)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{!isBalanced && (
|
||||
<p className="text-red-500 text-xs mt-2">차변과 대변의 합계가 일치하지 않습니다. (차이: {formatCurrency(Math.abs(totalDebit - totalCredit))})</p>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, idx) => (
|
||||
<tr key={idx} className="border-b border-stone-100">
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDcType(idx)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity ${line.dc_type === 'debit' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}
|
||||
title="클릭하여 차변/대변 전환"
|
||||
>
|
||||
{line.dc_type === 'debit' ? '차변' : '대변'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2" colSpan="2">
|
||||
<AccountCodeSelect
|
||||
value={line.account_code}
|
||||
onChange={(code, name) => {
|
||||
setLines(prev => prev.map((l, i) => i === idx ? { ...l, account_code: code, account_name: name } : l));
|
||||
}}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(idx, 'debit_amount', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-violet-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(idx, 'credit_amount', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-violet-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 */}
|
||||
<tr className={`font-bold ${isBalanced ? 'bg-green-50' : 'bg-red-50'}`}>
|
||||
<td colSpan="3" className="px-3 py-2 text-center text-sm">합계</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalDebit)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalCredit)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{!isBalanced && (
|
||||
<p className="text-red-500 text-xs mt-2">차변과 대변의 합계가 일치하지 않습니다. (차이: {formatCurrency(Math.abs(totalDebit - totalCredit))})</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-stone-100 flex justify-between">
|
||||
<div>
|
||||
{isEditMode && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-medium hover:bg-red-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
분개 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-stone-100 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || !isBalanced}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving && <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>}
|
||||
전표 생성
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || !isBalanced || loadingJournal}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving && <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>}
|
||||
{isEditMode ? '분개 수정' : '분개 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -564,8 +564,10 @@
|
||||
Route::post('/manual-store', [\App\Http\Controllers\Barobill\HometaxController::class, 'manualStore'])->name('manual-store');
|
||||
Route::put('/manual/{id}', [\App\Http\Controllers\Barobill\HometaxController::class, 'manualUpdate'])->name('manual-update');
|
||||
Route::delete('/manual/{id}', [\App\Http\Controllers\Barobill\HometaxController::class, 'manualDestroy'])->name('manual-destroy');
|
||||
// 분개 생성
|
||||
// 분개
|
||||
Route::post('/create-journal-entry', [\App\Http\Controllers\Barobill\HometaxController::class, 'createJournalEntry'])->name('create-journal-entry');
|
||||
Route::get('/journals', [\App\Http\Controllers\Barobill\HometaxController::class, 'getJournals'])->name('journals');
|
||||
Route::delete('/journals', [\App\Http\Controllers\Barobill\HometaxController::class, 'deleteJournals'])->name('journals.delete');
|
||||
// 카드내역 참조
|
||||
Route::get('/card-transactions', [\App\Http\Controllers\Barobill\HometaxController::class, 'cardTransactions'])->name('card-transactions');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user