feat: [ecard] 카드사용내역 분리/분개 열 분리

- 기존 '분개' 열을 '분리'(금액 나누기)와 '분개'(복식부기) 두 열로 분리
- SplitModal 텍스트를 '분개'에서 '분리'로 변경
- CSV 내보내기 텍스트도 '분리'로 통일
- 분리 열: 금액 분리 기능 (SplitModal)
- 분개 열: 복식부기 분개 기능 (CardJournalModal)
This commit is contained in:
김보곤
2026-02-24 17:49:30 +09:00
parent f62f0baeac
commit 1cd78585ae
2 changed files with 89 additions and 74 deletions

View File

@@ -1093,7 +1093,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
// 카드번호를 문자형으로 강제 (엑셀 과학적 표기 방지)
$cardNumText = $cardNum ? "=\"{$cardNum}\"" : '';
// 고유키로 분 데이터 확인
// 고유키로 분 데이터 확인
$uniqueKey = $log['uniqueKey'] ?? implode('|', [
$cardNum,
$log['useDt'] ?? '',
@@ -1117,7 +1117,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
$description = $log['description'] ?? $log['merchantBizType'] ?? '';
if ($hasSplits) {
// 분가 있는 경우: 원본 행 (합계 표시)
// 분가 있는 경우: 원본 행 (합계 표시)
fputcsv($handle, [
'원본',
$dateTime,
@@ -1133,11 +1133,11 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
number_format($tax),
$approvalNum,
'-',
'분됨 ('.count($splits).'건)',
'분됨 ('.count($splits).'건)',
'',
]);
// 각 분 행 출력
// 각 분 행 출력
foreach ($splits as $index => $split) {
$splitDeductionType = $split['deduction_type'] ?? $split['deductionType'] ?? 'deductible';
$splitDeductionText = ($splitDeductionType === 'non_deductible') ? '불공' : '공제';
@@ -1151,7 +1151,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
$splitMemo = $split['memo'] ?? '';
fputcsv($handle, [
'└ 분 #'.($index + 1),
'└ 분 #'.($index + 1),
'',
'',
'',
@@ -1170,7 +1170,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
]);
}
} else {
// 분가 없는 경우: 일반 행
// 분가 없는 경우: 일반 행
fputcsv($handle, [
'일반',
$dateTime,
@@ -1208,7 +1208,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
}
/**
* 분 내역 조회
* 분 내역 조회
*/
public function splits(Request $request): JsonResponse
{
@@ -1224,7 +1224,7 @@ public function splits(Request $request): JsonResponse
'data' => $splits,
]);
} catch (\Throwable $e) {
Log::error('분 내역 조회 오류: '.$e->getMessage());
Log::error('분 내역 조회 오류: '.$e->getMessage());
return response()->json([
'success' => false,
@@ -1234,7 +1234,7 @@ public function splits(Request $request): JsonResponse
}
/**
* 분 저장
* 분 저장
*/
public function saveSplits(Request $request): JsonResponse
{
@@ -1251,7 +1251,7 @@ public function saveSplits(Request $request): JsonResponse
]);
}
// 분 금액 합계 검증 (공급가액 + 부가세 합계)
// 분 금액 합계 검증 (공급가액 + 부가세 합계)
$originalAmount = floatval($originalData['originalAmount'] ?? 0);
$splitTotal = array_sum(array_map(function ($s) {
if (isset($s['supplyAmount']) && isset($s['tax'])) {
@@ -1264,7 +1264,7 @@ public function saveSplits(Request $request): JsonResponse
if (abs($originalAmount - $splitTotal) > 0.01) {
return response()->json([
'success' => false,
'error' => " 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다.",
'error' => " 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다.",
]);
}
@@ -1276,12 +1276,12 @@ public function saveSplits(Request $request): JsonResponse
return response()->json([
'success' => true,
'message' => '분가 저장되었습니다.',
'message' => '분가 저장되었습니다.',
'splitCount' => count($splits),
]);
} catch (\Throwable $e) {
DB::rollBack();
Log::error('분 저장 오류: '.$e->getMessage());
Log::error('분 저장 오류: '.$e->getMessage());
return response()->json([
'success' => false,
@@ -1291,7 +1291,7 @@ public function saveSplits(Request $request): JsonResponse
}
/**
* 분 삭제 (원본으로 복원)
* 분 삭제 (원본으로 복원)
*/
public function deleteSplits(Request $request): JsonResponse
{
@@ -1310,11 +1310,11 @@ public function deleteSplits(Request $request): JsonResponse
return response()->json([
'success' => true,
'message' => '분가 삭제되었습니다.',
'message' => '분가 삭제되었습니다.',
'deleted' => $deleted,
]);
} catch (\Throwable $e) {
Log::error('분 삭제 오류: '.$e->getMessage());
Log::error('분 삭제 오류: '.$e->getMessage());
return response()->json([
'success' => false,

View File

@@ -256,7 +256,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
);
};
// SplitModal Component - 분 모달
// SplitModal Component - 분 모달
const SplitModal = ({ isOpen, onClose, log, accountCodes, onSave, onReset, splits: existingSplits }) => {
const [splits, setSplits] = useState([]);
const [saving, setSaving] = useState(false);
@@ -266,7 +266,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
if (isOpen && log) {
const defaultDeductionType = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible');
if (existingSplits && existingSplits.length > 0) {
// 기존 분 로드
// 기존 분 로드
setSplits(existingSplits.map(s => {
const hasSupplyTax = s.split_supply_amount !== null && s.split_supply_amount !== undefined;
return {
@@ -281,7 +281,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
};
}));
} else {
// 새 분: 원본의 공급가액/세액로 1개 행 생성
// 새 분: 원본의 공급가액/세액로 1개 행 생성
const origSupply = log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0));
const origTax = log.effectiveTax ?? (log.tax || 0);
setSplits([{
@@ -333,7 +333,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
const handleSave = async () => {
if (!isValid) {
notify('분 합계금액이 원본 금액과 일치하지 않습니다.', 'error');
notify('분 합계금액이 원본 금액과 일치하지 않습니다.', 'error');
return;
}
setSaving(true);
@@ -343,7 +343,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
};
const handleReset = async () => {
if (!confirm('분를 삭제하고 원본 거래로 복구하시겠습니까?')) {
if (!confirm('분를 삭제하고 원본 거래로 복구하시겠습니까?')) {
return;
}
setResetting(true);
@@ -371,7 +371,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-stone-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">거래 </h3>
<h3 className="text-lg font-bold text-stone-900">거래 </h3>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
@@ -484,7 +484,7 @@ className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:rin
type="text"
value={split.memo}
onChange={(e) => updateSplit(index, { memo: e.target.value })}
placeholder=" 메모 (선택)"
placeholder=" 메모 (선택)"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
/>
</div>
@@ -510,13 +510,13 @@ className="mt-3 w-full py-2 border-2 border-dashed border-stone-300 text-stone-5
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
항목 추가
항목 추가
</button>
</div>
<div className="p-6 border-t border-stone-200 bg-stone-50">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-stone-500"> 합계</span>
<span className="text-sm text-stone-500"> 합계</span>
<span className={`font-bold ${isValid ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(splitTotal)}
{!isValid && (
@@ -533,7 +533,7 @@ className="mt-3 w-full py-2 border-2 border-dashed border-stone-300 text-stone-5
disabled={resetting}
className="py-2 px-4 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{resetting ? '복구 중...' : '분 복구'}
{resetting ? '복구 중...' : '분 복구'}
</button>
)}
<button
@@ -547,7 +547,7 @@ className="flex-1 py-2 border border-stone-300 text-stone-700 rounded-lg hover:b
disabled={!isValid || saving}
className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '저장 중...' : '분 저장'}
{saving ? '저장 중...' : '분 저장'}
</button>
</div>
</div>
@@ -1447,6 +1447,7 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
<tr>
<th className="px-3 py-4 bg-stone-50 w-10">분리</th>
<th className="px-3 py-4 bg-stone-50 w-10">분개</th>
<th className="px-4 py-4 bg-stone-50">사용일시</th>
<th className="px-4 py-4 bg-stone-50">카드정보</th>
@@ -1465,7 +1466,7 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
<tbody className="divide-y divide-stone-100">
{logs.length === 0 ? (
<tr>
<td colSpan="13" className="px-6 py-8 text-center text-stone-400">
<td colSpan="14" className="px-6 py-8 text-center text-stone-400">
해당 기간에 조회된 카드 사용내역이 없습니다.
</td>
</tr>
@@ -1479,33 +1480,49 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
<React.Fragment key={index}>
{/* 원본 거래 행 */}
<tr className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-purple-50/30' : ''} ${hasSplits ? 'bg-amber-50/50' : ''}`}>
{/* 분리 열 */}
<td className="px-3 py-3 text-center">
{(() => {
if (hasSplits) {
return (
<button
onClick={() => onOpenSplitModal(log, uniqueKey, logSplits)}
className="px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded text-[10px] font-bold hover:bg-amber-200 transition-colors"
title={`분리 ${logSplits.length}건 (클릭하여 수정)`}
>
{logSplits.length}
</button>
);
} else {
return (
<button
onClick={() => onOpenSplitModal(log, uniqueKey, [])}
className="p-1.5 text-amber-500 hover:bg-amber-100 rounded-lg transition-colors"
title="금액 분리"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
</button>
);
}
})()}
</td>
{/* 분개 열 */}
<td className="px-3 py-3 text-center">
{(() => {
const jInfo = journalMap[uniqueKey];
if (jInfo) {
// 복식부기 분개 완료
return (
<button
onClick={() => onOpenJournalModal(log, uniqueKey, true)}
className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-[10px] font-bold hover:bg-emerald-200 transition-colors"
title={`전표: ${jInfo.entry_no}`}
>
분개완료
</button>
);
} else if (hasSplits) {
// 구버전 splits만 존재
return (
<button
onClick={() => onOpenJournalModal(log, uniqueKey, false)}
className="px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded text-[10px] font-bold hover:bg-amber-200 transition-colors"
title="구버전 분개 → 복식부기 전환"
>
구버전
완료
</button>
);
} else {
// 분개 없음
return (
<button
onClick={() => onOpenJournalModal(log, uniqueKey, false)}
@@ -1606,7 +1623,7 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:outlin
<div className="text-xs text-emerald-600 mt-1">{journalMap[uniqueKey].entry_no}</div>
)}
{!journalMap[uniqueKey] && hasSplits && (
<div className="text-xs text-amber-600 mt-1">구버전({logSplits.length})</div>
<div className="text-xs text-amber-600 mt-1">분리됨({logSplits.length})</div>
)}
</td>
<td className={`px-4 py-3 text-right ${log.isAmountModified && log.modifiedSupplyAmount !== null ? 'bg-orange-50' : ''}`}>
@@ -1683,12 +1700,9 @@ className={`w-full px-2 py-1 text-sm text-right border rounded focus:outline-non
</>
)}
{hasSplits && (
<button
onClick={() => onOpenSplitModal(log, uniqueKey, logSplits)}
className="text-xs text-amber-600 hover:text-amber-700 underline"
>
분개 수정
</button>
<span className="text-xs text-amber-600">
분리됨 ({logSplits.length})
</span>
)}
</td>
<td className="px-3 py-3 text-center">
@@ -1727,7 +1741,7 @@ className="p-1 text-stone-400 hover:text-red-500 hover:bg-red-50 rounded transit
</div>
</td>
</tr>
{/* 분 행들 */}
{/* 분 행들 */}
{hasSplits && logSplits.map((split, splitIdx) => {
const splitSupply = split.split_supply_amount !== null && split.split_supply_amount !== undefined
? parseFloat(split.split_supply_amount)
@@ -1741,8 +1755,9 @@ className="p-1 text-stone-400 hover:text-red-500 hover:bg-red-50 rounded transit
<td className="px-3 py-2 text-center">
<div className="w-4 h-4 border-l-2 border-b-2 border-amber-300 ml-2"></div>
</td>
<td></td>
<td colSpan="2" className="px-4 py-2 text-xs text-stone-600">
#{splitIdx + 1} {split.memo && `- ${split.memo}`}
#{splitIdx + 1} {split.memo && `- ${split.memo}`}
</td>
<td className="px-4 py-2 text-center">
<span className={`px-2 py-1 rounded text-xs font-bold ${
@@ -1872,7 +1887,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
const [journalModalOpen, setJournalModalOpen] = useState(false);
const [journalModalLog, setJournalModalLog] = useState(null);
// 구버전 분개 관련 상태
// 분리 관련 상태
const [splits, setSplits] = useState({});
const [splitModalOpen, setSplitModalOpen] = useState(false);
const [splitModalLog, setSplitModalLog] = useState(null);
@@ -1976,12 +1991,12 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
setLoading(false);
}
// 분 데이터 로드
// 분 데이터 로드
loadSplits();
loadJournalStatuses();
};
// 분 데이터 로드
// 분 데이터 로드
const loadSplits = async () => {
try {
const params = new URLSearchParams({
@@ -1996,7 +2011,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
// useEffect에서 splits 변경 감지하여 재계산 처리
}
} catch (err) {
console.error('분 데이터 로드 오류:', err);
console.error('분 데이터 로드 오류:', err);
}
};
@@ -2082,7 +2097,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
}
};
// 요약 재계산: 분가 있는 거래는 원본 대신 분별 통계로 대체
// 요약 재계산: 분가 있는 거래는 원본 대신 분별 통계로 대체
// 수정된 공급가액/세액이 있으면 해당 값으로 합계 반영
const recalculateSummary = (currentLogs, allSplits) => {
if (!currentLogs || currentLogs.length === 0) return;
@@ -2101,7 +2116,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
const logSplits = allSplits[uniqueKey] || [];
if (logSplits.length > 0) {
// 분가 있는 거래: 각 분별로 계산
// 분가 있는 거래: 각 분별로 계산
logSplits.forEach(split => {
const splitSupply = split.split_supply_amount !== null && split.split_supply_amount !== undefined
? parseFloat(split.split_supply_amount) : parseFloat(split.split_amount || 0);
@@ -2123,7 +2138,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
}
});
} else {
// 분가 없는 거래: 수정된 금액(effectiveSupplyAmount/effectiveTax)으로 계산
// 분가 없는 거래: 수정된 금액(effectiveSupplyAmount/effectiveTax)으로 계산
const type = log.deductionType || 'non_deductible';
const effSupply = Math.abs(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)));
const effTax = Math.abs(log.effectiveTax ?? (log.tax || 0));
@@ -2162,14 +2177,14 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
if (logs.length > 0) {
const totalCount = summary.count || 0;
if (totalCount <= logs.length) {
// 전체 데이터가 한 페이지에 있으므로 분 반영 재계산
// 전체 데이터가 한 페이지에 있으므로 분 반영 재계산
recalculateSummary(logs, splits);
}
// 페이지네이션 중이면 백엔드 전체 통계 유지
}
}, [splits, logs]);
// 분 모달 열기
// 분 모달 열기
const handleOpenSplitModal = (log, uniqueKey, existingSplits = []) => {
setSplitModalLog(log);
setSplitModalKey(uniqueKey);
@@ -2177,7 +2192,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
setSplitModalOpen(true);
};
// 분 저장
// 분 저장
const handleSaveSplits = async (log, splitData) => {
try {
const uniqueKey = splitModalKey;
@@ -2205,18 +2220,18 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadSplits(); // 분 데이터 새로고침
loadSplits(); // 분 데이터 새로고침
} else {
notify(data.error || '분 저장 실패', 'error');
notify(data.error || '분 저장 실패', 'error');
}
} catch (err) {
notify('분 저장 오류: ' + err.message, 'error');
notify('분 저장 오류: ' + err.message, 'error');
}
};
// 분 삭제
// 분 삭제
const handleDeleteSplits = async (uniqueKey) => {
if (!confirm('분를 삭제하시겠습니까? 원본 거래로 복원됩니다.')) return;
if (!confirm('분를 삭제하시겠습니까? 원본 거래로 복원됩니다.')) return;
try {
const response = await fetch(API.deleteSplits, {
@@ -2232,16 +2247,16 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadSplits(); // 분 데이터 새로고침
loadSplits(); // 분 데이터 새로고침
} else {
notify(data.error || '분 삭제 실패', 'error');
notify(data.error || '분 삭제 실패', 'error');
}
} catch (err) {
notify('분 삭제 오류: ' + err.message, 'error');
notify('분 삭제 오류: ' + err.message, 'error');
}
};
// 분 복구 (모달에서 호출)
// 분 복구 (모달에서 호출)
const handleResetSplits = async (log) => {
const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`;
try {
@@ -2257,13 +2272,13 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6
const data = await response.json();
if (data.success) {
notify('분가 복구되었습니다.', 'success');
loadSplits(); // 분 데이터 새로고침
notify('분가 복구되었습니다.', 'success');
loadSplits(); // 분 데이터 새로고침
} else {
notify(data.error || '분 복구 실패', 'error');
notify(data.error || '분 복구 실패', 'error');
}
} catch (err) {
notify('분 복구 오류: ' + err.message, 'error');
notify('분 복구 오류: ' + err.message, 'error');
}
};