feat:법인카드 결제 기능 개선 (동적 항목 입력, 선불결제→결제 명칭 변경)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-20 10:32:58 +09:00
parent 14b4f5c98e
commit ed1a792d37
3 changed files with 152 additions and 51 deletions

View File

@@ -255,34 +255,40 @@ public function summary(): JsonResponse
'cardUsages' => $usageResult['perCard'],
'prepaidAmount' => (int) $prepayment->amount,
'prepaidMemo' => $prepayment->memo ?? '',
'prepaidItems' => $prepayment->items ?? [],
],
]);
}
/**
* 선불결제 금액 수정
* 결제 내역 수정
*/
public function updatePrepayment(Request $request): JsonResponse
{
$request->validate([
'amount' => 'required|integer|min:0',
'memo' => 'nullable|string|max:200',
'items' => 'present|array',
'items.*.date' => 'required|date',
'items.*.amount' => 'required|integer|min:0',
'items.*.description' => 'nullable|string|max:200',
]);
$tenantId = session('selected_tenant_id', 1);
$yearMonth = Carbon::now()->format('Y-m');
$items = collect($request->input('items', []))->filter(fn($item) => ($item['amount'] ?? 0) > 0)->values()->toArray();
$amount = collect($items)->sum('amount');
$prepayment = CorporateCardPrepayment::updateOrCreate(
['tenant_id' => $tenantId, 'year_month' => $yearMonth],
['amount' => $request->input('amount'), 'memo' => $request->input('memo')]
['amount' => $amount, 'items' => $items, 'memo' => null]
);
return response()->json([
'success' => true,
'message' => '선불결제 금액이 저장되었습니다.',
'message' => '결제 내역이 저장되었습니다.',
'data' => [
'amount' => (int) $prepayment->amount,
'memo' => $prepayment->memo ?? '',
'items' => $prepayment->items ?? [],
],
]);
}

View File

@@ -8,7 +8,11 @@ class CorporateCardPrepayment extends Model
{
protected $table = 'corporate_card_prepayments';
protected $fillable = ['tenant_id', 'year_month', 'amount', 'memo'];
protected $fillable = ['tenant_id', 'year_month', 'amount', 'memo', 'items'];
protected $casts = [
'items' => 'array',
];
public function scopeForTenant($query, int $tenantId)
{

View File

@@ -63,7 +63,9 @@ function CorporateCardsManagement() {
// 요약 데이터
const [summaryData, setSummaryData] = useState(null);
const [showPrepaymentModal, setShowPrepaymentModal] = useState(false);
const [prepaymentForm, setPrepaymentForm] = useState({ amount: '', memo: '' });
const [prepaymentItems, setPrepaymentItems] = useState([
{ date: new Date().toISOString().slice(0, 10), amount: '', description: '' }
]);
// CSRF 토큰
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
@@ -112,43 +114,85 @@ function CorporateCardsManagement() {
}
};
// 선불결제 저장
// 결제 항목 추가
const addPrepaymentItem = () => {
setPrepaymentItems(prev => [...prev, {
date: new Date().toISOString().slice(0, 10),
amount: '',
description: ''
}]);
};
// 결제 항목 삭제
const removePrepaymentItem = (index) => {
if (prepaymentItems.length <= 1) return;
setPrepaymentItems(prev => prev.filter((_, i) => i !== index));
};
// 결제 항목 수정
const updatePrepaymentItem = (index, field, value) => {
setPrepaymentItems(prev => prev.map((item, i) =>
i === index ? { ...item, [field]: value } : item
));
};
// 결제 합계 계산
const prepaymentTotal = prepaymentItems.reduce(
(sum, item) => sum + (parseInt(parseInputCurrency(item.amount)) || 0), 0
);
// 결제 내역 저장
const handleSavePrepayment = async () => {
try {
const items = prepaymentItems
.filter(item => item.amount && parseInt(parseInputCurrency(item.amount)) > 0)
.map(item => ({
date: item.date,
amount: parseInt(parseInputCurrency(item.amount)),
description: item.description,
}));
const response = await fetch('/finance/corporate-cards/prepayment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({
amount: parseInt(parseInputCurrency(prepaymentForm.amount)) || 0,
memo: prepaymentForm.memo,
}),
body: JSON.stringify({ items }),
});
const result = await response.json();
if (result.success) {
setSummaryData(prev => ({
...prev,
prepaidAmount: result.data.amount,
prepaidMemo: result.data.memo,
prepaidItems: result.data.items,
}));
setShowPrepaymentModal(false);
} else {
alert(result.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('선불결제 저장 실패:', error);
console.error('결제 내역 저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
// 선불결제 수정 모달 열기
// 결제 내역 수정 모달 열기
const openPrepaymentModal = () => {
setPrepaymentForm({
amount: summaryData?.prepaidAmount ? String(summaryData.prepaidAmount) : '',
memo: summaryData?.prepaidMemo || '',
});
const items = summaryData?.prepaidItems;
if (items && items.length > 0) {
setPrepaymentItems(items.map(item => ({
date: item.date,
amount: String(item.amount),
description: item.description || '',
})));
} else {
setPrepaymentItems([{
date: new Date().toISOString().slice(0, 10),
amount: '',
description: ''
}]);
}
setShowPrepaymentModal(true);
};
@@ -491,10 +535,10 @@ className="p-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded transition-c
: '-'}
</p>
</div>
{/* 5. 선불결제 */}
{/* 5. 결제 */}
<div className="bg-white rounded-xl border border-amber-200 p-4 bg-amber-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-amber-700">선불결제</span>
<span className="text-xs text-amber-700">결제</span>
<button
onClick={openPrepaymentModal}
className="text-xs px-1.5 py-0.5 bg-amber-100 hover:bg-amber-200 text-amber-700 rounded transition-colors"
@@ -505,8 +549,8 @@ className="text-xs px-1.5 py-0.5 bg-amber-100 hover:bg-amber-200 text-amber-700
<p className="text-xl font-bold text-amber-600">
{summaryData ? formatCurrency(summaryData.prepaidAmount) : '0'}
</p>
<p className="text-xs text-amber-400 mt-1 truncate" title={summaryData?.prepaidMemo || ''}>
{summaryData?.prepaidMemo || '미입력'}
<p className="text-xs text-amber-400 mt-1 truncate">
{summaryData?.prepaidItems?.length > 0 ? `${summaryData.prepaidItems.length}건` : '미입력'}
</p>
</div>
{/* 6. 잔여 한도 */}
@@ -518,7 +562,7 @@ className="text-xs px-1.5 py-0.5 bg-amber-100 hover:bg-amber-200 text-amber-700
<p className="text-xl font-bold text-emerald-600">
{formatCurrency(totalLimit - (summaryData?.billingUsage || 0) + (summaryData?.prepaidAmount || 0))}
</p>
<p className="text-xs text-emerald-400 mt-1">한도-사용+결제</p>
<p className="text-xs text-emerald-400 mt-1">한도-사용+결제</p>
</div>
</div>
@@ -878,12 +922,12 @@ className="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded
</div>
</div>
)}
{/* 선불결제 수정 모달 */}
{/* 결제 내역 수정 모달 */}
{showPrepaymentModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-sm mx-4">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">선불결제 금액 수정</h3>
<h3 className="text-lg font-bold text-gray-900">결제 내역 수정</h3>
<button
onClick={() => setShowPrepaymentModal(false)}
className="p-1 hover:bg-gray-100 rounded-lg"
@@ -891,30 +935,77 @@ className="p-1 hover:bg-gray-100 rounded-lg"
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">선불결제 금액</label>
<input
type="text"
value={formatInputCurrency(prepaymentForm.amount)}
onChange={(e) => setPrepaymentForm(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))}
placeholder="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 text-right text-lg font-bold"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모 (선택)</label>
<input
type="text"
value={prepaymentForm.memo}
onChange={(e) => setPrepaymentForm(prev => ({ ...prev, memo: e.target.value }))}
placeholder="예: 2월 선결제"
maxLength={200}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500"
/>
</div>
{/* 항목 헤더 */}
<div className="grid grid-cols-12 gap-2 mb-2 text-xs font-medium text-gray-500">
<div className="col-span-3">날짜</div>
<div className="col-span-3">금액</div>
<div className="col-span-5">내용</div>
<div className="col-span-1"></div>
</div>
<div className="flex gap-3 mt-6">
{/* 항목 리스트 */}
<div className="space-y-2">
{prepaymentItems.map((item, index) => (
<div key={index} className="grid grid-cols-12 gap-2 items-start">
<div className="col-span-3">
<input
type="date"
value={item.date}
onChange={(e) => updatePrepaymentItem(index, 'date', e.target.value)}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 text-sm"
/>
</div>
<div className="col-span-3">
<input
type="text"
value={formatInputCurrency(item.amount)}
onChange={(e) => updatePrepaymentItem(index, 'amount', parseInputCurrency(e.target.value))}
placeholder="0"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 text-sm text-right"
/>
</div>
<div className="col-span-5">
<textarea
value={item.description}
onChange={(e) => updatePrepaymentItem(index, 'description', e.target.value)}
placeholder="결제 내용"
rows={2}
maxLength={200}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 text-sm resize-none"
/>
</div>
<div className="col-span-1 flex items-center justify-center pt-1">
<button
onClick={() => removePrepaymentItem(index)}
disabled={prepaymentItems.length <= 1}
className={`p-1 rounded transition-colors ${prepaymentItems.length <= 1 ? 'text-gray-300 cursor-not-allowed' : 'text-red-400 hover:text-red-600 hover:bg-red-50'}`}
title="항목 삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{/* 항목 추가 버튼 */}
<button
onClick={addPrepaymentItem}
className="mt-3 flex items-center gap-1 text-sm text-amber-600 hover:text-amber-700 font-medium"
>
<Plus className="w-4 h-4" />
항목 추가
</button>
{/* 합계 */}
<div className="mt-4 pt-3 border-t border-gray-200 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">합계</span>
<span className="text-lg font-bold text-amber-600">{formatCurrency(prepaymentTotal)}</span>
</div>
{/* 버튼 */}
<div className="flex gap-3 mt-5">
<button
onClick={() => setShowPrepaymentModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"