From a629cb6fcdda8b56d516ec0eb8c60f86eb4a8790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Feb 2026 17:29:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=ED=99=88=ED=83=9D=EC=8A=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B6=9C/=EB=A7=A4=EC=9E=85=20=EC=88=98=EB=8F=99=EC=9E=85?= =?UTF-8?q?=EB=A0=A5,=20=EB=B6=84=EA=B0=9C,=20=EC=B9=B4=EB=93=9C=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=B0=B8=EC=A1=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수동입력: MAN-YYYYMMDD-NNN 형식 자동채번, 생성/수정/삭제 - 분개: 세금계산서에서 일반전표 자동 생성 (매출/매입 패턴) - 카드내역 참조: 수동입력 시 카드사용내역에서 금액/거래처 자동채움 - 테이블에 액션 컬럼 추가 (분개/수정/삭제 버튼) Co-Authored-By: Claude Opus 4.6 --- .../Barobill/HometaxController.php | 320 +++++++++ .../views/barobill/hometax/index.blade.php | 675 +++++++++++++++++- routes/web.php | 8 + 3 files changed, 996 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Barobill/HometaxController.php b/app/Http/Controllers/Barobill/HometaxController.php index 301c3025..27226eda 100644 --- a/app/Http/Controllers/Barobill/HometaxController.php +++ b/app/Http/Controllers/Barobill/HometaxController.php @@ -5,11 +5,16 @@ use App\Http\Controllers\Controller; use App\Models\Barobill\BarobillConfig; 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\Tenants\Tenant; use App\Services\Barobill\HometaxSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\View\View; @@ -1401,4 +1406,319 @@ public function toggleChecked(Request $request, HometaxSyncService $syncService) ]); } } + + /** + * 수동 세금계산서 저장 + */ + public function manualStore(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $validated = $request->validate([ + 'invoice_type' => 'required|in:sales,purchase', + 'write_date' => 'required|date', + 'invoicer_corp_name' => 'required|string|max:200', + 'invoicer_corp_num' => 'nullable|string|max:20', + 'invoicee_corp_name' => 'required|string|max:200', + 'invoicee_corp_num' => 'nullable|string|max:20', + 'supply_amount' => 'required|numeric|min:0', + 'tax_amount' => 'required|numeric|min:0', + 'item_name' => 'nullable|string|max:200', + 'remark' => 'nullable|string|max:500', + 'tax_type' => 'nullable|integer|in:1,2,3', + 'purpose_type' => 'nullable|integer|in:1,2', + ]); + + // MAN-YYYYMMDD-NNN 형식 자동채번 + $dateStr = date('Ymd', strtotime($validated['write_date'])); + $lastNum = HometaxInvoice::where('tenant_id', $tenantId) + ->where('nts_confirm_num', 'like', "MAN-{$dateStr}-%") + ->orderByRaw('CAST(SUBSTRING_INDEX(nts_confirm_num, "-", -1) AS UNSIGNED) DESC') + ->value('nts_confirm_num'); + + $seq = 1; + if ($lastNum) { + $parts = explode('-', $lastNum); + $seq = (int)end($parts) + 1; + } + $ntsConfirmNum = sprintf('MAN-%s-%03d', $dateStr, $seq); + + $totalAmount = (float)$validated['supply_amount'] + (float)$validated['tax_amount']; + + $invoice = HometaxInvoice::create([ + 'tenant_id' => $tenantId, + 'nts_confirm_num' => $ntsConfirmNum, + 'invoice_type' => $validated['invoice_type'], + 'write_date' => $validated['write_date'], + 'issue_date' => $validated['write_date'], + 'invoicer_corp_name' => $validated['invoicer_corp_name'], + 'invoicer_corp_num' => $validated['invoicer_corp_num'] ?? '', + 'invoicee_corp_name' => $validated['invoicee_corp_name'], + 'invoicee_corp_num' => $validated['invoicee_corp_num'] ?? '', + 'supply_amount' => $validated['supply_amount'], + 'tax_amount' => $validated['tax_amount'], + 'total_amount' => $totalAmount, + 'item_name' => $validated['item_name'] ?? '', + 'remark' => $validated['remark'] ?? '', + 'tax_type' => $validated['tax_type'] ?? 1, + 'purpose_type' => $validated['purpose_type'] ?? 1, + 'synced_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'message' => '수동 세금계산서가 등록되었습니다.', + 'data' => $invoice, + ]); + } catch (\Illuminate\Validation\ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => '입력값 오류: ' . implode(', ', $e->validator->errors()->all()), + ], 422); + } catch (\Throwable $e) { + Log::error('수동 세금계산서 저장 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '저장 오류: ' . $e->getMessage(), + ]); + } + } + + /** + * 수동 세금계산서 수정 (MAN- 건만 가능) + */ + public function manualUpdate(Request $request, int $id): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $invoice = HometaxInvoice::where('id', $id) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if (!str_starts_with($invoice->nts_confirm_num, 'MAN-')) { + return response()->json([ + 'success' => false, + 'error' => '수동 입력 건만 수정할 수 있습니다.', + ], 403); + } + + $validated = $request->validate([ + 'invoice_type' => 'sometimes|in:sales,purchase', + 'write_date' => 'sometimes|date', + 'invoicer_corp_name' => 'sometimes|string|max:200', + 'invoicer_corp_num' => 'nullable|string|max:20', + 'invoicee_corp_name' => 'sometimes|string|max:200', + 'invoicee_corp_num' => 'nullable|string|max:20', + 'supply_amount' => 'sometimes|numeric|min:0', + 'tax_amount' => 'sometimes|numeric|min:0', + 'item_name' => 'nullable|string|max:200', + 'remark' => 'nullable|string|max:500', + 'tax_type' => 'nullable|integer|in:1,2,3', + 'purpose_type' => 'nullable|integer|in:1,2', + ]); + + if (isset($validated['supply_amount']) || isset($validated['tax_amount'])) { + $supply = $validated['supply_amount'] ?? $invoice->supply_amount; + $tax = $validated['tax_amount'] ?? $invoice->tax_amount; + $validated['total_amount'] = (float)$supply + (float)$tax; + } + + $invoice->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '수정되었습니다.', + 'data' => $invoice->fresh(), + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'success' => false, + 'error' => '해당 세금계산서를 찾을 수 없습니다.', + ], 404); + } catch (\Throwable $e) { + Log::error('수동 세금계산서 수정 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '수정 오류: ' . $e->getMessage(), + ]); + } + } + + /** + * 수동 세금계산서 삭제 (MAN- 건만 가능) + */ + public function manualDestroy(int $id): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $invoice = HometaxInvoice::where('id', $id) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if (!str_starts_with($invoice->nts_confirm_num, 'MAN-')) { + return response()->json([ + 'success' => false, + 'error' => '수동 입력 건만 삭제할 수 있습니다.', + ], 403); + } + + $invoice->delete(); + + return response()->json([ + 'success' => true, + 'message' => '삭제되었습니다.', + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'success' => false, + 'error' => '해당 세금계산서를 찾을 수 없습니다.', + ], 404); + } catch (\Throwable $e) { + Log::error('수동 세금계산서 삭제 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '삭제 오류: ' . $e->getMessage(), + ]); + } + } + + /** + * 세금계산서에서 분개(일반전표) 생성 + */ + public function createJournalEntry(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $validated = $request->validate([ + 'invoice_id' => 'required|integer', + 'lines' => 'required|array|min:1', + 'lines.*.dc_type' => 'required|in:debit,credit', + 'lines.*.account_code' => 'required|string', + 'lines.*.account_name' => 'required|string', + 'lines.*.debit_amount' => 'required|numeric|min:0', + 'lines.*.credit_amount' => 'required|numeric|min:0', + 'lines.*.description' => 'nullable|string', + ]); + + $invoice = HometaxInvoice::where('id', $validated['invoice_id']) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + $result = DB::transaction(function () use ($tenantId, $invoice, $validated) { + $entryNo = JournalEntry::generateEntryNo($tenantId, $invoice->write_date); + + $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; + }); + + return response()->json([ + 'success' => true, + 'message' => "전표 {$result->entry_no}가 생성되었습니다.", + 'data' => $result->load('lines'), + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'success' => false, + 'error' => '해당 세금계산서를 찾을 수 없습니다.', + ], 404); + } catch (\Throwable $e) { + Log::error('분개 생성 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '분개 생성 오류: ' . $e->getMessage(), + ]); + } + } + + /** + * 카드내역 조회 (수동입력 참조용) + */ + public function cardTransactions(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $startDate = $request->input('startDate', date('Y-m-d', strtotime('-1 month'))); + $endDate = $request->input('endDate', date('Y-m-d')); + $search = $request->input('search', ''); + + $query = BarobillCardTransaction::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderByDesc('use_date') + ->orderByDesc('use_time'); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('merchant_name', 'like', "%{$search}%") + ->orWhere('merchant_biz_num', 'like', "%{$search}%") + ->orWhere('approval_num', 'like', "%{$search}%"); + }); + } + + $transactions = $query->limit(100)->get()->map(function ($t) { + return [ + 'id' => $t->id, + 'useDate' => $t->use_date, + 'useTime' => $t->use_time, + 'merchantName' => $t->merchant_name, + 'merchantBizNum' => $t->merchant_biz_num, + 'approvalNum' => $t->approval_num, + 'approvalAmount' => (float)$t->approval_amount, + 'approvalAmountFormatted' => number_format($t->approval_amount), + 'tax' => (float)($t->tax ?? 0), + 'supplyAmount' => (float)($t->modified_supply_amount ?: ($t->approval_amount - ($t->tax ?? 0))), + 'cardNum' => $t->card_num ? substr($t->card_num, -4) : '', + 'cardCompanyName' => $t->card_company_name ?? '', + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => $transactions, + ]); + } catch (\Throwable $e) { + Log::error('카드내역 조회 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '조회 오류: ' . $e->getMessage(), + ]); + } + } } diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index 57ebfc17..89688eef 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -78,6 +78,11 @@ scrapUrl: '{{ route("barobill.hometax.scrap-url") }}', refreshScrap: '{{ route("barobill.hometax.refresh-scrap") }}', diagnose: '{{ route("barobill.hometax.diagnose") }}', + manualStore: '{{ route("barobill.hometax.manual-store") }}', + manualUpdate: '{{ route("barobill.hometax.manual-update", ["id" => "__ID__"]) }}', + manualDestroy: '{{ route("barobill.hometax.manual-destroy", ["id" => "__ID__"]) }}', + createJournalEntry: '{{ route("barobill.hometax.create-journal-entry") }}', + cardTransactions: '{{ route("barobill.hometax.card-transactions") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -170,7 +175,10 @@ className={`px-6 py-3 text-sm font-medium rounded-lg transition-all ${ type, onExport, onRequestCollect, - summary // 합계 정보 + summary, + onJournalEntry, + onEditManual, + onDeleteManual }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); @@ -236,13 +244,14 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded 영수
청구 문서
형태 발급
형태 - 상태 + 상태 + 액션 {invoices.length === 0 ? ( - + 해당 기간에 조회된 세금계산서가 없습니다. @@ -257,7 +266,11 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded {/* 구분 */} - 홈택 + {inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') ? ( + 수동 + ) : ( + 홈택 + )} {/* 작성일자 */} @@ -312,8 +325,36 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded 정발급 {/* 상태 */} - - 전송완료 + + {inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') ? ( + 수동입력 + ) : ( + 전송완료 + )} + + {/* 액션 */} + +
+ + {inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') && ( + <> + + + + )} +
); @@ -334,7 +375,7 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded {formatCurrency(summary.totalAmount)} - + )} @@ -369,6 +410,14 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded const [diagnoseResult, setDiagnoseResult] = useState(null); const [diagnosing, setDiagnosing] = useState(false); + // 수동입력 모달 + const [showManualModal, setShowManualModal] = useState(false); + const [manualEditData, setManualEditData] = useState(null); + + // 분개 모달 + const [showJournalModal, setShowJournalModal] = useState(false); + const [journalInvoice, setJournalInvoice] = useState(null); + // 초기 로드 (매출만 먼저 조회) useEffect(() => { loadSalesData(); @@ -648,6 +697,80 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded } }; + // 수동입력 삭제 + const handleDeleteManual = async (inv) => { + if (!confirm(`"${inv.invoicerCorpName || inv.invoiceeCorpName}" 세금계산서를 삭제하시겠습니까?`)) return; + try { + const url = API.manualDestroy.replace('__ID__', inv.id); + const res = await fetch(url, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': CSRF_TOKEN } + }); + const data = await res.json(); + if (data.success) { + notify('삭제되었습니다.', 'success'); + loadCurrentTabData(); + } else { + notify(data.error || '삭제 실패', 'error'); + } + } catch (err) { + notify('삭제 오류: ' + err.message, 'error'); + } + }; + + // 수동입력 저장 (생성/수정) + const handleManualSave = async (formData) => { + try { + const isEdit = !!formData.id; + const url = isEdit + ? API.manualUpdate.replace('__ID__', formData.id) + : API.manualStore; + const method = isEdit ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN + }, + body: JSON.stringify(formData) + }); + const data = await res.json(); + if (data.success) { + notify(data.message, 'success'); + setShowManualModal(false); + loadCurrentTabData(); + } else { + notify(data.error || '저장 실패', 'error'); + } + } catch (err) { + notify('저장 오류: ' + err.message, 'error'); + } + }; + + // 분개 생성 + const handleJournalSave = async (invoiceId, lines) => { + try { + const res = await fetch(API.createJournalEntry, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN + }, + body: JSON.stringify({ invoice_id: invoiceId, lines }) + }); + const data = await res.json(); + if (data.success) { + notify(data.message, 'success'); + setShowJournalModal(false); + } else { + notify(data.error || '분개 생성 실패', 'error'); + } + } catch (err) { + notify('분개 생성 오류: ' + err.message, 'error'); + } + }; + // 이번 달 버튼 const handleThisMonth = () => { const dates = getMonthDates(0); @@ -984,6 +1107,18 @@ className="flex items-center gap-2 text-sm font-semibold rounded-md disabled:opa 매입 + {/* Error Display */} @@ -1024,6 +1159,15 @@ className="flex items-center gap-2 text-sm font-semibold rounded-md disabled:opa onExport={handleExport} onRequestCollect={handleRequestCollect} summary={currentInvoiceSummary} + onJournalEntry={(inv) => { + setJournalInvoice(inv); + setShowJournalModal(true); + }} + onEditManual={(inv) => { + setManualEditData(inv); + setShowManualModal(true); + }} + onDeleteManual={handleDeleteManual} /> )} @@ -1178,6 +1322,523 @@ className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium )} + + {/* 수동입력 모달 */} + {showManualModal && ( + setShowManualModal(false)} + onSave={handleManualSave} + editData={manualEditData} + invoiceType={activeTab} + /> + )} + + {/* 분개 모달 */} + {showJournalModal && journalInvoice && ( + { setShowJournalModal(false); setJournalInvoice(null); }} + onSave={handleJournalSave} + invoice={journalInvoice} + /> + )} + + ); + }; + + // ============================================ + // ManualInputModal - 수동입력 모달 + // ============================================ + const ManualInputModal = ({ isOpen, onClose, onSave, editData, invoiceType }) => { + const [form, setForm] = useState({ + invoice_type: editData?.invoiceType || editData?.invoice_type || invoiceType || 'sales', + write_date: editData?.writeDate || editData?.write_date || formatKoreanDate(getKoreanNow()), + invoicer_corp_name: editData?.invoicerCorpName || editData?.invoicer_corp_name || '', + invoicer_corp_num: editData?.invoicerCorpNum || editData?.invoicer_corp_num || '', + invoicee_corp_name: editData?.invoiceeCorpName || editData?.invoicee_corp_name || '', + invoicee_corp_num: editData?.invoiceeCorpNum || editData?.invoicee_corp_num || '', + supply_amount: editData?.supplyAmount || editData?.supply_amount || '', + tax_amount: editData?.taxAmount || editData?.tax_amount || '', + item_name: editData?.itemName || editData?.item_name || '', + remark: editData?.remark || '', + tax_type: editData?.taxType || editData?.tax_type || 1, + purpose_type: editData?.purposeType || editData?.purpose_type || 1, + }); + const [showCardPicker, setShowCardPicker] = useState(false); + const [saving, setSaving] = useState(false); + + const handleChange = (field, value) => { + setForm(prev => ({ ...prev, [field]: value })); + }; + + const totalAmount = (parseFloat(form.supply_amount) || 0) + (parseFloat(form.tax_amount) || 0); + + const handleSubmit = async () => { + if (!form.write_date || !form.supply_amount) { + notify('작성일자와 공급가액은 필수입니다.', 'warning'); + return; + } + // 매출이면 invoicee(공급받는자), 매입이면 invoicer(공급자) 필수 + if (form.invoice_type === 'sales' && !form.invoicee_corp_name) { + notify('공급받는자(거래처)명을 입력해주세요.', 'warning'); + return; + } + if (form.invoice_type === 'purchase' && !form.invoicer_corp_name) { + notify('공급자(거래처)명을 입력해주세요.', 'warning'); + return; + } + setSaving(true); + const submitData = { ...form }; + if (editData?.id) submitData.id = editData.id; + await onSave(submitData); + setSaving(false); + }; + + const handleCardSelect = (card) => { + setForm(prev => ({ + ...prev, + supply_amount: card.supplyAmount || (card.approvalAmount - (card.tax || 0)), + tax_amount: card.tax || 0, + invoicer_corp_name: card.merchantName || prev.invoicer_corp_name, + invoicer_corp_num: card.merchantBizNum || prev.invoicer_corp_num, + write_date: card.useDate || prev.write_date, + remark: `카드승인 ${card.approvalNum || ''}`.trim(), + })); + setShowCardPicker(false); + }; + + const isSales = form.invoice_type === 'sales'; + + return ( +
+
+
+

+ {editData ? '세금계산서 수정' : '세금계산서 수동 입력'} +

+ +
+
+ {/* 매출/매입 구분 + 작성일자 */} +
+
+ + +
+
+ + handleChange('write_date', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + /> +
+
+ + {/* 거래처 정보 */} +
+
+

+ {isSales ? '공급받는자 정보' : '공급자 정보'} +

+ {!editData && form.invoice_type === 'purchase' && ( + + )} +
+
+
+ + handleChange(isSales ? 'invoicee_corp_name' : 'invoicer_corp_name', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + placeholder="거래처명" + /> +
+
+ + handleChange(isSales ? 'invoicee_corp_num' : 'invoicer_corp_num', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + placeholder="000-00-00000" + /> +
+
+
+ + {/* 금액 */} +
+
+ + handleChange('supply_amount', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + placeholder="0" + /> +
+
+ + handleChange('tax_amount', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + placeholder="0" + /> +
+
+ +
+ {new Intl.NumberFormat('ko-KR').format(totalAmount)} +
+
+
+ + {/* 품목 + 과세유형 */} +
+
+ + handleChange('item_name', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + placeholder="품목명" + /> +
+
+ + +
+
+ + {/* 비고 */} +
+ + handleChange('remark', e.target.value)} + className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none" + placeholder="메모" + /> +
+
+
+ + +
+
+ + {/* 카드내역 피커 (중첩 모달) */} + {showCardPicker && ( + setShowCardPicker(false)} + /> + )} +
+ ); + }; + + // ============================================ + // JournalEntryModal - 분개 생성 모달 + // ============================================ + const JournalEntryModal = ({ isOpen, onClose, onSave, invoice }) => { + 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); + const taxAmount = parseFloat(invoice.taxAmount || invoice.tax_amount || 0); + const totalAmount = parseFloat(invoice.totalAmount || invoice.total_amount || (supplyAmount + taxAmount)); + + // 분개 라인 초기값 + const getDefaultLines = () => { + if (isSales) { + return [ + { dc_type: 'debit', account_code: '108', account_name: '외상매출금', debit_amount: totalAmount, credit_amount: 0, description: '' }, + { dc_type: 'credit', account_code: '401', account_name: '상품매출', debit_amount: 0, credit_amount: supplyAmount, description: '' }, + { dc_type: 'credit', account_code: '255', account_name: '부가세예수금', debit_amount: 0, credit_amount: taxAmount, description: '' }, + ]; + } else { + return [ + { dc_type: 'debit', account_code: '501', account_name: '상품매입', debit_amount: supplyAmount, credit_amount: 0, description: '' }, + { dc_type: 'debit', account_code: '135', account_name: '부가세대급금', debit_amount: taxAmount, credit_amount: 0, description: '' }, + { dc_type: 'credit', account_code: '251', account_name: '외상매입금', debit_amount: 0, credit_amount: totalAmount, description: '' }, + ]; + } + }; + + const [lines, setLines] = useState(getDefaultLines()); + const [saving, setSaving] = useState(false); + + const updateLine = (idx, field, value) => { + setLines(prev => prev.map((l, i) => i === idx ? { ...l, [field]: value } : l)); + }; + + const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0); + const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0); + const isBalanced = Math.abs(totalDebit - totalCredit) < 1; + + const handleSubmit = async () => { + if (!isBalanced) { + notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning'); + return; + } + setSaving(true); + await onSave(invoice.id, lines); + setSaving(false); + }; + + const tradingPartner = isSales + ? (invoice.invoiceeCorpName || invoice.invoicee_corp_name || '') + : (invoice.invoicerCorpName || invoice.invoicer_corp_name || ''); + + return ( +
+
+
+

분개 생성 (일반전표)

+ +
+
+ {/* 세금계산서 정보 */} +
+

세금계산서 정보

+
+
구분: {isSales ? '매출' : '매입'}
+
거래처: {tradingPartner}
+
공급가액: {formatCurrency(supplyAmount)}
+
세액: {formatCurrency(taxAmount)}
+
+
+ + {/* 분개 라인 테이블 */} +
+

분개 내역

+ + + + + + + + + + + + {lines.map((line, idx) => ( + + + + + + + + ))} + {/* 합계 */} + + + + + + +
차/대계정코드계정과목차변금액대변금액
+ + {line.dc_type === 'debit' ? '차변' : '대변'} + + + updateLine(idx, 'account_code', e.target.value)} + className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-center focus:ring-1 focus:ring-violet-500 outline-none" + /> + + updateLine(idx, 'account_name', e.target.value)} + className="w-full px-2 py-1 border border-stone-200 rounded text-sm focus:ring-1 focus:ring-violet-500 outline-none" + /> + + 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" + /> + + 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" + /> +
합계{formatCurrency(totalDebit)}{formatCurrency(totalCredit)}
+ {!isBalanced && ( +

차변과 대변의 합계가 일치하지 않습니다. (차이: {formatCurrency(Math.abs(totalDebit - totalCredit))})

+ )} +
+
+
+ + +
+
+
+ ); + }; + + // ============================================ + // CardTransactionPicker - 카드내역 선택 모달 + // ============================================ + const CardTransactionPicker = ({ onSelect, onClose }) => { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [startDate, setStartDate] = useState(formatKoreanDate(new Date(getKoreanNow().setMonth(getKoreanNow().getMonth() - 1)))); + const [endDate, setEndDate] = useState(formatKoreanDate(getKoreanNow())); + + const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); + + const loadTransactions = async () => { + setLoading(true); + try { + const params = new URLSearchParams({ startDate, endDate, search }); + const res = await fetch(`${API.cardTransactions}?${params}`); + const data = await res.json(); + if (data.success) { + setTransactions(data.data || []); + } + } catch (err) { + console.error('카드내역 조회 오류:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadTransactions(); }, []); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+

카드내역 불러오기

+ +
+ {/* 검색 */} +
+
+ setStartDate(e.target.value)} className="px-2 py-1.5 border border-stone-300 rounded text-sm" /> + ~ + setEndDate(e.target.value)} className="px-2 py-1.5 border border-stone-300 rounded text-sm" /> + setSearch(e.target.value)} + placeholder="가맹점/승인번호" + className="flex-1 px-2 py-1.5 border border-stone-300 rounded text-sm" + /> + +
+
+ {/* 목록 */} +
+ {loading ? ( +
+
+ 조회 중... +
+ ) : transactions.length === 0 ? ( +
카드내역이 없습니다.
+ ) : ( + + + + + + + + + + + + {transactions.map((t) => ( + onSelect(t)}> + + + + + + + ))} + +
날짜가맹점금액승인번호선택
{t.useDate}{t.merchantName}{t.approvalAmountFormatted}{t.approvalNum} + +
+ )} +
+
); }; diff --git a/routes/web.php b/routes/web.php index 425fac18..665ce111 100644 --- a/routes/web.php +++ b/routes/web.php @@ -525,6 +525,14 @@ Route::post('/sync', [\App\Http\Controllers\Barobill\HometaxController::class, 'sync'])->name('sync'); Route::post('/update-memo', [\App\Http\Controllers\Barobill\HometaxController::class, 'updateMemo'])->name('update-memo'); Route::post('/toggle-checked', [\App\Http\Controllers\Barobill\HometaxController::class, 'toggleChecked'])->name('toggle-checked'); + // 수동입력 CRUD + 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('/card-transactions', [\App\Http\Controllers\Barobill\HometaxController::class, 'cardTransactions'])->name('card-transactions'); }); });