feat:전자세금계산서 공급자 기초정보 설정 모달 구현
- EtaxController에 getSupplier/updateSupplier 메서드 추가 - etax 라우트 그룹에 GET/POST /supplier 라우트 추가 - SupplierSettingsModal React 컴포넌트 구현 (톱니바퀴 아이콘) - IssueForm이 supplier state를 props로 참조하도록 변경 - manager_phone → manager_hp 필드명 버그 수정 - FIXED_SUPPLIER → INITIAL_SUPPLIER 상수 리네이밍 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -339,6 +339,82 @@ public function sendToNts(Request $request): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공급자 기초정보 조회
|
||||
*/
|
||||
public function getSupplier(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
if (!$tenantId) {
|
||||
return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400);
|
||||
}
|
||||
|
||||
$member = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
if (!$member) {
|
||||
return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'supplier' => [
|
||||
'bizno' => $member->biz_no,
|
||||
'name' => $member->corp_name ?? '',
|
||||
'ceo' => $member->ceo_name ?? '',
|
||||
'addr' => $member->addr ?? '',
|
||||
'bizType' => $member->biz_type ?? '',
|
||||
'bizClass' => $member->biz_class ?? '',
|
||||
'contact' => $member->manager_name ?? '',
|
||||
'contactPhone' => $member->manager_hp ?? '',
|
||||
'email' => $member->manager_email ?? '',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공급자 기초정보 수정
|
||||
*/
|
||||
public function updateSupplier(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
if (!$tenantId) {
|
||||
return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400);
|
||||
}
|
||||
|
||||
$member = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
if (!$member) {
|
||||
return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'corp_name' => 'required|string|max:100',
|
||||
'ceo_name' => 'required|string|max:50',
|
||||
'addr' => 'nullable|string|max:255',
|
||||
'biz_type' => 'nullable|string|max:100',
|
||||
'biz_class' => 'nullable|string|max:100',
|
||||
'manager_name' => 'nullable|string|max:50',
|
||||
'manager_email' => 'nullable|email|max:100',
|
||||
'manager_hp' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$member->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '공급자 정보가 수정되었습니다.',
|
||||
'supplier' => [
|
||||
'bizno' => $member->biz_no,
|
||||
'name' => $member->corp_name ?? '',
|
||||
'ceo' => $member->ceo_name ?? '',
|
||||
'addr' => $member->addr ?? '',
|
||||
'bizType' => $member->biz_type ?? '',
|
||||
'bizClass' => $member->biz_class ?? '',
|
||||
'contact' => $member->manager_name ?? '',
|
||||
'contactPhone' => $member->manager_hp ?? '',
|
||||
'email' => $member->manager_email ?? '',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
*/
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
issue: '{{ route("barobill.etax.issue") }}',
|
||||
sendToNts: '{{ route("barobill.etax.send-to-nts") }}',
|
||||
delete: '{{ route("barobill.etax.delete") }}',
|
||||
supplier: '{{ route("barobill.etax.supplier") }}',
|
||||
supplierUpdate: '{{ route("barobill.etax.supplier.update") }}',
|
||||
};
|
||||
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
@@ -85,7 +87,7 @@
|
||||
];
|
||||
|
||||
// 공급자 정보 (현재 테넌트의 바로빌 회원사 정보)
|
||||
const FIXED_SUPPLIER = {
|
||||
const INITIAL_SUPPLIER = {
|
||||
bizno: '{{ $barobillMember?->biz_no ?? "" }}',
|
||||
name: '{{ $barobillMember?->corp_name ?? $currentTenant?->company_name ?? "" }}',
|
||||
ceo: '{{ $barobillMember?->ceo_name ?? $currentTenant?->ceo_name ?? "" }}',
|
||||
@@ -93,7 +95,7 @@
|
||||
bizType: '{{ $barobillMember?->biz_type ?? "" }}',
|
||||
bizClass: '{{ $barobillMember?->biz_class ?? "" }}',
|
||||
contact: '{{ $barobillMember?->manager_name ?? "" }}',
|
||||
contactPhone: '{{ $barobillMember?->manager_phone ?? "" }}',
|
||||
contactPhone: '{{ $barobillMember?->manager_hp ?? "" }}',
|
||||
email: '{{ $barobillMember?->manager_email ?? $currentTenant?->email ?? "" }}'
|
||||
};
|
||||
|
||||
@@ -130,7 +132,7 @@
|
||||
);
|
||||
|
||||
// IssueForm Component
|
||||
const IssueForm = ({ onIssue, onCancel }) => {
|
||||
const IssueForm = ({ onIssue, onCancel, supplier }) => {
|
||||
const generateRandomData = () => {
|
||||
let items = [];
|
||||
let supplyDate;
|
||||
@@ -183,15 +185,15 @@
|
||||
}
|
||||
|
||||
return {
|
||||
supplierBizno: FIXED_SUPPLIER.bizno,
|
||||
supplierName: FIXED_SUPPLIER.name,
|
||||
supplierCeo: FIXED_SUPPLIER.ceo,
|
||||
supplierAddr: FIXED_SUPPLIER.addr,
|
||||
supplierBizType: FIXED_SUPPLIER.bizType,
|
||||
supplierBizClass: FIXED_SUPPLIER.bizClass,
|
||||
supplierContact: FIXED_SUPPLIER.contact,
|
||||
supplierContactPhone: FIXED_SUPPLIER.contactPhone,
|
||||
supplierEmail: FIXED_SUPPLIER.email,
|
||||
supplierBizno: supplier.bizno,
|
||||
supplierName: supplier.name,
|
||||
supplierCeo: supplier.ceo,
|
||||
supplierAddr: supplier.addr,
|
||||
supplierBizType: supplier.bizType,
|
||||
supplierBizClass: supplier.bizClass,
|
||||
supplierContact: supplier.contact,
|
||||
supplierContactPhone: supplier.contactPhone,
|
||||
supplierEmail: supplier.email,
|
||||
...recipientData,
|
||||
supplyDate: formatLocalDate(supplyDate),
|
||||
items,
|
||||
@@ -808,6 +810,127 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
);
|
||||
};
|
||||
|
||||
// SupplierSettingsModal Component
|
||||
const SupplierSettingsModal = ({ supplier, onClose, onSaved }) => {
|
||||
const [form, setForm] = useState({
|
||||
corp_name: supplier.name || '',
|
||||
ceo_name: supplier.ceo || '',
|
||||
addr: supplier.addr || '',
|
||||
biz_type: supplier.bizType || '',
|
||||
biz_class: supplier.bizClass || '',
|
||||
manager_name: supplier.contact || '',
|
||||
manager_hp: supplier.contactPhone || '',
|
||||
manager_email: supplier.email || '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await fetch(API.supplierUpdate, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
onSaved(result.supplier);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('저장 중 오류가 발생했습니다: ' + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const labelClass = "block text-sm font-medium text-stone-700 mb-1";
|
||||
const inputClass = "w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none";
|
||||
const readonlyClass = "w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-500";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-stone-100 flex justify-between items-center bg-stone-50">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-900">공급자 기초정보 설정</h3>
|
||||
<p className="text-xs text-stone-500 mt-0.5">세금계산서 발행 시 사용되는 공급자 정보입니다</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-stone-200 rounded-full transition-colors">
|
||||
<svg className="w-5 h-5 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className={labelClass}>사업자번호</label>
|
||||
<input type="text" className={readonlyClass} value={supplier.bizno} readOnly />
|
||||
<p className="text-xs text-stone-400 mt-1">사업자번호는 변경할 수 없습니다</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>상호명 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className={inputClass} value={form.corp_name} onChange={e => handleChange('corp_name', e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>대표자명 <span className="text-red-500">*</span></label>
|
||||
<input type="text" className={inputClass} value={form.ceo_name} onChange={e => handleChange('ceo_name', e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>주소</label>
|
||||
<input type="text" className={inputClass} value={form.addr} onChange={e => handleChange('addr', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>업태</label>
|
||||
<input type="text" className={inputClass} value={form.biz_type} onChange={e => handleChange('biz_type', e.target.value)} placeholder="예: 정보통신업" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>종목</label>
|
||||
<input type="text" className={inputClass} value={form.biz_class} onChange={e => handleChange('biz_class', e.target.value)} placeholder="예: 소프트웨어 개발" />
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-stone-200" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>담당자명</label>
|
||||
<input type="text" className={inputClass} value={form.manager_name} onChange={e => handleChange('manager_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>연락처</label>
|
||||
<input type="text" className={inputClass} value={form.manager_hp} onChange={e => handleChange('manager_hp', e.target.value)} placeholder="예: 02-1234-5678" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>이메일</label>
|
||||
<input type="email" className={inputClass} value={form.manager_email} onChange={e => handleChange('manager_email', e.target.value)} placeholder="예: tax@company.com" />
|
||||
</div>
|
||||
</form>
|
||||
<div className="p-5 border-t border-stone-100 bg-stone-50 flex justify-end gap-3">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 text-stone-600 hover:text-stone-800 font-medium transition-colors">취소</button>
|
||||
<button type="submit" disabled={saving} onClick={(e) => { e.target.closest('.bg-stone-50').previousElementSibling.querySelector('form').requestSubmit(); }} className="px-5 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{saving ? (
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> 저장 중...</>
|
||||
) : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ApiLogs Component
|
||||
const ApiLogs = ({ logs, onClear }) => {
|
||||
if (logs.length === 0) return null;
|
||||
@@ -864,6 +987,8 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
const [showIssueForm, setShowIssueForm] = useState(false);
|
||||
const [apiLogs, setApiLogs] = useState([]);
|
||||
const [supplier, setSupplier] = useState(INITIAL_SUPPLIER);
|
||||
const [showSupplierModal, setShowSupplierModal] = useState(false);
|
||||
|
||||
// 날짜 필터 상태 (기본: 현재 월)
|
||||
const currentMonth = getMonthDates(0);
|
||||
@@ -1069,6 +1194,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
<h2 className="text-lg font-bold text-stone-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
전자세금계산서 발행
|
||||
<button onClick={() => setShowSupplierModal(true)} className="p-1.5 text-stone-400 hover:text-stone-600 hover:bg-stone-100 rounded-lg transition-colors" title="공급자 기초정보 설정">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
</button>
|
||||
</h2>
|
||||
{!showIssueForm && (
|
||||
<button onClick={() => setShowIssueForm(true)} className={`px-4 py-2 text-white rounded-lg font-medium transition-colors flex items-center gap-2 ${IS_TEST_MODE ? 'bg-blue-600 hover:bg-blue-700' : 'bg-red-600 hover:bg-red-700'}`}>
|
||||
@@ -1077,7 +1205,7 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showIssueForm && <IssueForm onIssue={handleIssue} onCancel={() => setShowIssueForm(false)} />}
|
||||
{showIssueForm && <IssueForm onIssue={handleIssue} onCancel={() => setShowIssueForm(false)} supplier={supplier} />}
|
||||
</div>
|
||||
|
||||
{/* Invoice List */}
|
||||
@@ -1103,6 +1231,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedInvoice && <InvoiceDetailModal invoice={selectedInvoice} onClose={() => setSelectedInvoice(null)} />}
|
||||
|
||||
{/* Supplier Settings Modal */}
|
||||
{showSupplierModal && <SupplierSettingsModal supplier={supplier} onClose={() => setShowSupplierModal(false)} onSaved={(updated) => setSupplier(updated)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -432,6 +432,8 @@
|
||||
Route::post('/issue', [\App\Http\Controllers\Barobill\EtaxController::class, 'issue'])->name('issue');
|
||||
Route::post('/send-to-nts', [\App\Http\Controllers\Barobill\EtaxController::class, 'sendToNts'])->name('send-to-nts');
|
||||
Route::post('/delete', [\App\Http\Controllers\Barobill\EtaxController::class, 'delete'])->name('delete');
|
||||
Route::get('/supplier', [\App\Http\Controllers\Barobill\EtaxController::class, 'getSupplier'])->name('supplier');
|
||||
Route::post('/supplier', [\App\Http\Controllers\Barobill\EtaxController::class, 'updateSupplier'])->name('supplier.update');
|
||||
});
|
||||
|
||||
// 계좌 입출금내역 (React 페이지)
|
||||
|
||||
Reference in New Issue
Block a user