feat: [corporate-card] 카드분리 기능 추가
- 결제 내역 수정 모달에 카드분리 버튼 추가 - 카드별 배분금액 직접 입력 UI - 균등 배분 / 비율 배분 / 해제 버튼 - 배분 합계 검증 (일치해야 저장 가능) - card_splits 데이터 JSON 저장 (기존 items 확장) - cardDeductions 로직: card_splits 우선 적용, 없으면 기존 비율 배분
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user