diff --git a/app/Http/Controllers/Finance/CorporateCardController.php b/app/Http/Controllers/Finance/CorporateCardController.php index a7402d62..21e5bd8b 100644 --- a/app/Http/Controllers/Finance/CorporateCardController.php +++ b/app/Http/Controllers/Finance/CorporateCardController.php @@ -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); diff --git a/resources/views/finance/corporate-cards.blade.php b/resources/views/finance/corporate-cards.blade.php index e4539c3c..9a9c2edc 100644 --- a/resources/views/finance/corporate-cards.blade.php +++ b/resources/views/finance/corporate-cards.blade.php @@ -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- {formatCurrency(prepaymentTotal)}원 + {/* 카드분리 영역 */} + {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 ( +
+ + + {isOpen && ( +
+
+ + 배분할 금액: {formatCurrency(itemAmount)}원 + +
+ + + +
+
+ + {/* 카드 목록 헤더 */} +
+
카드
+
기초한도
+
바로빌 사용
+
배분금액
+
+ + {/* 카드 목록 */} +
+ {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 ( +
+
+ + {card.cardCompany} {card.cardName} + {last4} + +
+
+ + {card.cardType === 'credit' && card.creditLimit > 0 ? formatCurrency(card.creditLimit) : '-'} + +
+
+ + {rawUsage > 0 ? formatCurrency(rawUsage) : '0'} + +
+
+ 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" + /> +
+
+ ); + })} +
+ + {/* 배분 합계 */} +
+ 배분 합계 +
+ + {formatCurrency(splitSum)}원 + + {splitSum !== itemAmount && ( + + (미배분: {formatCurrency(itemAmount - splitSum)}원) + + )} + {splitSum === itemAmount && ( + 일치 + )} +
+
+
+ )} +
+ ); + })} + {/* 버튼 */}