feat:법인카드 결제 기능 개선 (동적 항목 입력, 선불결제→결제 명칭 변경)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ?? [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user