feat: [corporate-card] 카드분리 기능 추가

- 결제 내역 수정 모달에 카드분리 버튼 추가
- 카드별 배분금액 직접 입력 UI
- 균등 배분 / 비율 배분 / 해제 버튼
- 배분 합계 검증 (일치해야 저장 가능)
- card_splits 데이터 JSON 저장 (기존 items 확장)
- cardDeductions 로직: card_splits 우선 적용, 없으면 기존 비율 배분
This commit is contained in:
김보곤
2026-03-05 23:19:19 +09:00
parent 159a7a9331
commit 7a277c6986
2 changed files with 298 additions and 42 deletions

View File

@@ -278,6 +278,8 @@ public function updatePrepayment(Request $request): JsonResponse
'items.*.date' => 'required|date',
'items.*.amount' => 'required|integer|min:0',
'items.*.description' => 'nullable|string|max:200',
'items.*.card_splits' => 'nullable|array',
'items.*.card_splits.*' => 'integer|min:0',
]);
$tenantId = session('selected_tenant_id', 1);

View File

@@ -117,12 +117,16 @@ function CorporateCardsManagement() {
}
};
// 카드분리 열림 상태 (항목 인덱스 또는 null)
const [splitOpenIndex, setSplitOpenIndex] = useState(null);
// 결제 항목 추가
const addPrepaymentItem = () => {
setPrepaymentItems(prev => [...prev, {
date: new Date().toISOString().slice(0, 10),
amount: '',
description: ''
description: '',
card_splits: null,
}]);
};
@@ -139,8 +143,92 @@ function CorporateCardsManagement() {
));
};
// 카드분리 - 배분금액 수정
const updateCardSplit = (itemIndex, cardNum, value) => {
setPrepaymentItems(prev => prev.map((item, i) => {
if (i !== itemIndex) return item;
const splits = { ...(item.card_splits || {}) };
const parsed = parseInt(parseInputCurrency(value)) || 0;
splits[cardNum] = parsed;
return { ...item, card_splits: splits };
}));
};
// 카드분리 - 균등 배분
const distributeEvenly = (itemIndex) => {
const item = prepaymentItems[itemIndex];
const total = parseInt(parseInputCurrency(item.amount)) || 0;
if (total <= 0) return;
const activeList = cards.filter(c => c.status === 'active');
if (activeList.length === 0) return;
const base = Math.floor(total / activeList.length);
const remainder = total - base * activeList.length;
const splits = {};
activeList.forEach((c, idx) => {
const key = c.cardNumber.replace(/-/g, '');
splits[key] = base + (idx < remainder ? 1 : 0);
});
setPrepaymentItems(prev => prev.map((it, i) => i === itemIndex ? { ...it, card_splits: splits } : it));
};
// 카드분리 - 사용비율 배분
const distributeByRatio = (itemIndex) => {
const item = prepaymentItems[itemIndex];
const total = parseInt(parseInputCurrency(item.amount)) || 0;
if (total <= 0) return;
const activeList = cards.filter(c => c.status === 'active').map(c => ({
key: c.cardNumber.replace(/-/g, ''),
raw: getRawCardUsage(c.cardNumber),
})).filter(c => c.raw > 0);
if (activeList.length === 0) return distributeEvenly(itemIndex);
const totalRaw = activeList.reduce((s, c) => s + c.raw, 0);
if (totalRaw <= 0) return distributeEvenly(itemIndex);
const splits = {};
let distributed = 0;
activeList.forEach((c, idx) => {
if (idx === activeList.length - 1) {
splits[c.key] = total - distributed;
} else {
const amt = Math.round(total * (c.raw / totalRaw));
splits[c.key] = amt;
distributed += amt;
}
});
// 사용금액 0인 카드는 0으로
cards.filter(c => c.status === 'active').forEach(c => {
const key = c.cardNumber.replace(/-/g, '');
if (!(key in splits)) splits[key] = 0;
});
setPrepaymentItems(prev => prev.map((it, i) => i === itemIndex ? { ...it, card_splits: splits } : it));
};
// 카드분리 - 초기화
const resetCardSplit = (itemIndex) => {
setPrepaymentItems(prev => prev.map((it, i) => i === itemIndex ? { ...it, card_splits: null } : it));
setSplitOpenIndex(null);
};
// 카드분리 합계
const getCardSplitTotal = (splits) => {
if (!splits) return 0;
return Object.values(splits).reduce((s, v) => s + (v || 0), 0);
};
// 결제 내역 저장
const handleSavePrepayment = async () => {
// 카드분리 검증
for (let i = 0; i < prepaymentItems.length; i++) {
const item = prepaymentItems[i];
const itemAmount = parseInt(parseInputCurrency(item.amount)) || 0;
if (item.card_splits && itemAmount > 0) {
const splitTotal = getCardSplitTotal(item.card_splits);
if (splitTotal !== itemAmount) {
alert(`항목 ${i + 1}의 카드분리 배분 합계(${formatCurrency(splitTotal)}원)가 결제 금액(${formatCurrency(itemAmount)}원)과 일치하지 않습니다.`);
return;
}
}
}
try {
const items = prepaymentItems
.filter(item => item.amount && parseInt(parseInputCurrency(item.amount)) > 0)
@@ -148,6 +236,7 @@ function CorporateCardsManagement() {
date: item.date,
amount: parseInt(parseInputCurrency(item.amount)),
description: item.description,
card_splits: item.card_splits || null,
}));
const response = await fetch('/finance/corporate-cards/prepayment', {
@@ -183,14 +272,17 @@ function CorporateCardsManagement() {
date: item.date,
amount: String(item.amount),
description: item.description || '',
card_splits: item.card_splits || null,
})));
} else {
setPrepaymentItems([{
date: new Date().toISOString().slice(0, 10),
amount: '',
description: ''
description: '',
card_splits: null,
}]);
}
setSplitOpenIndex(null);
setShowPrepaymentModal(true);
};
@@ -403,58 +495,82 @@ function CorporateCardsManagement() {
return summaryData.cardUsages[normalized] || 0;
};
// 선결제 스마트 배분: 1단계 한도초과 우선 차감 → 2단계 잔여 비율 배분
// 선결제 카드별 차감액 계산: card_splits 우선, 없으면 비율 배분
const cardDeductions = (() => {
const totalRaw = summaryData?.billingUsage || 0;
const prepaid = summaryData?.prepaidAmount || 0;
if (totalRaw <= 0 || prepaid <= 0) return {};
const activeCards = cards
.filter(c => c.status === 'active')
.map(c => ({
key: c.cardNumber.replace(/-/g, ''),
raw: getRawCardUsage(c.cardNumber),
limit: c.cardType === 'credit' ? c.creditLimit : 0,
}))
.filter(c => c.raw > 0);
if (activeCards.length === 0) return {};
let remaining = Math.min(prepaid, totalRaw);
const prepaidItems = summaryData?.prepaidItems || [];
const result = {};
activeCards.forEach(c => result[c.key] = 0);
// 1단계: 한도 초과 카드에 초과분만큼 우선 차감
for (const card of activeCards) {
if (remaining <= 0) break;
if (card.limit > 0 && card.raw > card.limit) {
const excess = card.raw - card.limit;
const deduct = Math.min(excess, remaining);
result[card.key] += deduct;
remaining -= deduct;
// 1. card_splits가 있는 항목: 사용자 지정 배분 적용
let splitTotal = 0;
for (const item of prepaidItems) {
if (item.card_splits && Object.keys(item.card_splits).length > 0) {
for (const [cardNum, amount] of Object.entries(item.card_splits)) {
result[cardNum] = (result[cardNum] || 0) + (amount || 0);
splitTotal += (amount || 0);
}
}
}
// 2단계: 잔여 금액을 현재 사용액 비율 배분
if (remaining > 0) {
const withCurrent = activeCards
.map(c => ({ ...c, current: c.raw - result[c.key] }))
.filter(c => c.current > 0);
const totalCurrent = withCurrent.reduce((sum, c) => sum + c.current, 0);
// 2. card_splits가 없는 항목의 금액: 기존 비율 배분
const unsplitAmount = prepaidItems
.filter(item => !item.card_splits || Object.keys(item.card_splits).length === 0)
.reduce((sum, item) => sum + (item.amount || 0), 0);
if (totalCurrent > 0) {
let distributed = 0;
for (let i = 0; i < withCurrent.length; i++) {
const card = withCurrent[i];
let deduct;
if (i === withCurrent.length - 1) {
deduct = remaining - distributed;
} else {
deduct = Math.round(remaining * (card.current / totalCurrent));
if (unsplitAmount > 0) {
const activeCards = cards
.filter(c => c.status === 'active')
.map(c => ({
key: c.cardNumber.replace(/-/g, ''),
raw: getRawCardUsage(c.cardNumber),
limit: c.cardType === 'credit' ? c.creditLimit : 0,
}))
.filter(c => c.raw > 0);
if (activeCards.length > 0) {
let remaining = Math.min(unsplitAmount, totalRaw - splitTotal);
if (remaining > 0) {
activeCards.forEach(c => { if (!result[c.key]) result[c.key] = 0; });
// 1단계: 한도 초과 카드에 초과분만큼 우선 차감
for (const card of activeCards) {
if (remaining <= 0) break;
const currentDeduction = result[card.key] || 0;
const effectiveRaw = card.raw - currentDeduction;
if (card.limit > 0 && effectiveRaw > card.limit) {
const excess = effectiveRaw - card.limit;
const deduct = Math.min(excess, remaining);
result[card.key] += deduct;
remaining -= deduct;
}
}
// 2단계: 잔여 금액을 현재 사용액 비율로 배분
if (remaining > 0) {
const withCurrent = activeCards
.map(c => ({ ...c, current: c.raw - (result[c.key] || 0) }))
.filter(c => c.current > 0);
const totalCurrent = withCurrent.reduce((sum, c) => sum + c.current, 0);
if (totalCurrent > 0) {
let distributed = 0;
for (let i = 0; i < withCurrent.length; i++) {
const card = withCurrent[i];
let deduct;
if (i === withCurrent.length - 1) {
deduct = remaining - distributed;
} else {
deduct = Math.round(remaining * (card.current / totalCurrent));
}
deduct = Math.min(deduct, card.current);
result[card.key] += deduct;
distributed += deduct;
}
}
}
deduct = Math.min(deduct, card.current);
result[card.key] += deduct;
distributed += deduct;
}
}
}
@@ -1026,6 +1142,144 @@ className="mt-3 flex items-center gap-1 text-sm text-amber-600 hover:text-amber-
<span className="text-lg font-bold text-amber-600">{formatCurrency(prepaymentTotal)}</span>
</div>
{/* 카드분리 영역 */}
{prepaymentItems.map((item, index) => {
const itemAmount = parseInt(parseInputCurrency(item.amount)) || 0;
if (itemAmount <= 0) return null;
const isOpen = splitOpenIndex === index;
const hasSplit = item.card_splits && Object.keys(item.card_splits).length > 0;
const splitSum = getCardSplitTotal(item.card_splits);
const activeList = cards.filter(c => c.status === 'active');
return (
<div key={`split-${index}`} className="mt-4">
<button
onClick={() => {
if (isOpen) {
setSplitOpenIndex(null);
} else {
// 카드분리 열 때 splits 초기화 (없으면)
if (!item.card_splits) {
const splits = {};
activeList.forEach(c => {
splits[c.cardNumber.replace(/-/g, '')] = 0;
});
updatePrepaymentItem(index, 'card_splits', splits);
}
setSplitOpenIndex(index);
}
}}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
hasSplit
? 'bg-violet-50 border-violet-300 text-violet-700 hover:bg-violet-100'
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
}`}
>
<span>
{prepaymentItems.length > 1 ? `항목 ${index + 1} ` : ''}카드분리
{hasSplit && <span className="ml-1 text-xs">({Object.values(item.card_splits).filter(v => v > 0).length} 배분)</span>}
</span>
<span className="text-xs">
{hasSplit ? `${formatCurrency(splitSum)}원 / ${formatCurrency(itemAmount)}원` : '미설정'}
{isOpen ? ' ▲' : ' ▼'}
</span>
</button>
{isOpen && (
<div className="mt-2 border border-violet-200 rounded-lg bg-violet-50/50 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">
배분할 금액: <strong className="text-violet-700">{formatCurrency(itemAmount)}</strong>
</span>
<div className="flex gap-1">
<button onClick={() => distributeEvenly(index)}
className="px-2 py-1 text-xs bg-white border border-gray-200 text-gray-600 rounded hover:bg-gray-50">
균등 배분
</button>
<button onClick={() => distributeByRatio(index)}
className="px-2 py-1 text-xs bg-white border border-gray-200 text-gray-600 rounded hover:bg-gray-50">
비율 배분
</button>
<button onClick={() => resetCardSplit(index)}
className="px-2 py-1 text-xs bg-white border border-red-200 text-red-500 rounded hover:bg-red-50">
해제
</button>
</div>
</div>
{/* 카드 목록 헤더 */}
<div className="flex gap-2 mb-1 text-xs font-medium text-gray-500 px-1">
<div style={{flex: '1 1 0'}}>카드</div>
<div style={{width: '90px', flexShrink: 0, textAlign: 'right'}}>기초한도</div>
<div style={{width: '90px', flexShrink: 0, textAlign: 'right'}}>바로빌 사용</div>
<div style={{width: '120px', flexShrink: 0, textAlign: 'right'}}>배분금액</div>
</div>
{/* 카드 목록 */}
<div className="space-y-1">
{activeList.map(card => {
const cardKey = card.cardNumber.replace(/-/g, '');
const splitVal = (item.card_splits && item.card_splits[cardKey]) || 0;
const rawUsage = getRawCardUsage(card.cardNumber);
const last4 = card.cardNumber.slice(-4);
return (
<div key={cardKey} className="flex gap-2 items-center bg-white rounded-lg px-2 py-1.5 border border-gray-100">
<div style={{flex: '1 1 0', minWidth: 0}}>
<span className="text-sm text-gray-800 truncate block">
{card.cardCompany} {card.cardName}
<span className="text-gray-400 ml-1 font-mono text-xs">{last4}</span>
</span>
</div>
<div style={{width: '90px', flexShrink: 0, textAlign: 'right'}}>
<span className="text-xs text-gray-500">
{card.cardType === 'credit' && card.creditLimit > 0 ? formatCurrency(card.creditLimit) : '-'}
</span>
</div>
<div style={{width: '90px', flexShrink: 0, textAlign: 'right'}}>
<span className="text-xs text-gray-500">
{rawUsage > 0 ? formatCurrency(rawUsage) : '0'}
</span>
</div>
<div style={{width: '120px', flexShrink: 0}}>
<input
type="text"
value={formatInputCurrency(splitVal)}
onChange={(e) => updateCardSplit(index, cardKey, e.target.value)}
placeholder="0"
className="w-full px-2 py-1 border border-gray-300 rounded text-sm text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
</div>
);
})}
</div>
{/* 배분 합계 */}
<div className={`mt-3 pt-2 border-t flex items-center justify-between text-sm ${
splitSum === itemAmount ? 'border-green-200' : 'border-red-200'
}`}>
<span className="font-medium text-gray-700">배분 합계</span>
<div className="text-right">
<span className={`font-bold ${splitSum === itemAmount ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(splitSum)}
</span>
{splitSum !== itemAmount && (
<span className="text-xs text-red-500 ml-2">
(미배분: {formatCurrency(itemAmount - splitSum)})
</span>
)}
{splitSum === itemAmount && (
<span className="text-xs text-green-500 ml-2">일치</span>
)}
</div>
</div>
</div>
)}
</div>
);
})}
{/* 버튼 */}
<div className="flex gap-3 mt-5">
<button