feat: [ecard] 분개 모달에 거래처 선택 드롭다운 추가

- TradingPartnerSelect 컴포넌트 추가 (거래처 검색/선택)
- CardJournalModal 테이블에 거래처 컬럼 추가
- 분개 라인별 trading_partner_id/name 저장/조회 지원
- EcardController storeJournal/getJournal에 거래처 필드 추가
This commit is contained in:
김보곤
2026-02-24 16:00:30 +09:00
parent ee8596db7c
commit da06425efd
2 changed files with 119 additions and 9 deletions

View File

@@ -1865,6 +1865,8 @@ public function storeJournal(Request $request): JsonResponse
'lines.*.account_name' => 'required|string|max:100',
'lines.*.debit_amount' => 'required|integer|min:0',
'lines.*.credit_amount' => 'required|integer|min:0',
'lines.*.trading_partner_id' => 'nullable|integer',
'lines.*.trading_partner_name' => 'nullable|string|max:100',
'lines.*.description' => 'nullable|string|max:300',
]);
@@ -1926,6 +1928,8 @@ public function storeJournal(Request $request): JsonResponse
'account_name' => $line['account_name'],
'debit_amount' => $line['debit_amount'],
'credit_amount' => $line['credit_amount'],
'trading_partner_id' => $line['trading_partner_id'] ?? null,
'trading_partner_name' => $line['trading_partner_name'] ?? null,
'description' => $line['description'] ?? null,
]);
}
@@ -2003,6 +2007,8 @@ public function getJournal(Request $request): JsonResponse
'account_name' => $line->account_name,
'debit_amount' => $line->debit_amount,
'credit_amount' => $line->credit_amount,
'trading_partner_id' => $line->trading_partner_id,
'trading_partner_name' => $line->trading_partner_name,
'description' => $line->description,
]),
],

View File

@@ -556,6 +556,96 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
);
};
// ============================================
// TradingPartnerSelect - 거래처 드롭다운
// ============================================
const TradingPartnerSelect = ({ value, valueName, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [partners, setPartners] = useState([]);
const [loading, setLoading] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef(null);
const listRef = useRef(null);
const displayText = valueName || '';
// 검색어 변경 시 API 조회
useEffect(() => {
if (!isOpen) return;
setLoading(true);
const url = search
? `/finance/journal-entries/trading-partners?search=${encodeURIComponent(search)}`
: '/finance/journal-entries/trading-partners';
fetch(url)
.then(res => res.json())
.then(data => { if (data.success) setPartners(data.data || []); })
.catch(() => {})
.finally(() => setLoading(false));
}, [isOpen, search]);
useEffect(() => { setHighlightIndex(-1); }, [search]);
useEffect(() => {
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (partner) => {
onChange(partner.id, partner.name);
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
};
const handleClear = (e) => { e.stopPropagation(); onChange(null, ''); setSearch(''); };
const handleKeyDown = (e) => {
const maxIdx = partners.length - 1;
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex(prev => prev < maxIdx ? prev + 1 : 0); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex(prev => prev > 0 ? prev - 1 : maxIdx); }
else if (e.key === 'Enter' && partners.length > 0) { e.preventDefault(); handleSelect(partners[highlightIndex >= 0 ? highlightIndex : 0]); }
else if (e.key === 'Escape') { setIsOpen(false); setSearch(''); setHighlightIndex(-1); }
};
return (
<div ref={containerRef} className="relative">
<div onClick={() => setIsOpen(!isOpen)}
className={`w-full px-3 py-1.5 text-sm border rounded-lg cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-purple-500 ring-1 ring-purple-500' : 'border-stone-200'} bg-white`}>
<span className={displayText ? 'text-stone-900 truncate' : 'text-stone-400 text-xs'}>{displayText || '거래처 선택'}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{value && <button onClick={handleClear} className="text-stone-400 hover:text-stone-600">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>}
<svg className={`w-3 h-3 text-stone-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
{isOpen && (
<div className="absolute z-[60] mt-1 w-72 bg-white border border-stone-200 rounded-lg shadow-lg">
<div className="p-2 border-b border-stone-100">
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown}
placeholder="거래처명 또는 사업자번호 검색..." className="w-full px-2.5 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-1 focus:ring-purple-500 outline-none" autoFocus />
</div>
<div ref={listRef} className="max-h-48 overflow-y-auto">
{loading ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 ...</div>
) : partners.length === 0 ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 결과 없음</div>
) : partners.map((p, index) => (
<div key={p.id} onClick={() => handleSelect(p)}
className={`px-3 py-1.5 text-sm cursor-pointer ${index === highlightIndex ? 'bg-purple-600 text-white font-semibold' : value === p.id ? 'bg-purple-100 text-purple-700' : 'text-stone-700 hover:bg-purple-50'}`}>
<span className="font-medium">{p.name}</span>
{p.biz_no && <span className={`ml-1 text-xs ${index === highlightIndex ? 'text-purple-100' : 'text-stone-400'}`}>({p.biz_no})</span>}
</div>
))}
</div>
</div>
)}
</div>
);
};
// ============================================
// CardJournalModal - 복식부기 분개 모달
// ============================================
@@ -580,14 +670,14 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
if (isDeductible) {
return [
{ dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: supplyAmount, credit_amount: 0, description: '' },
{ dc_type: 'debit', account_code: '135', account_name: '부가세대급금', debit_amount: taxAmount, credit_amount: 0, description: '' },
{ dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, description: '' },
{ dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: supplyAmount, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' },
{ dc_type: 'debit', account_code: '135', account_name: '부가세대급금', debit_amount: taxAmount, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' },
{ dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, trading_partner_id: null, trading_partner_name: '', description: '' },
];
} else {
return [
{ dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: totalAmount, credit_amount: 0, description: '' },
{ dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, description: '' },
{ dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: totalAmount, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' },
{ dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, trading_partner_id: null, trading_partner_name: '', description: '' },
];
}
};
@@ -610,6 +700,8 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
account_name: l.account_name,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
trading_partner_id: l.trading_partner_id || null,
trading_partner_name: l.trading_partner_name || '',
description: l.description || '',
})));
setIsEditMode(true);
@@ -627,6 +719,8 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
account_name: l.account_name,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
trading_partner_id: l.trading_partner_id || null,
trading_partner_name: l.trading_partner_name || '',
description: l.description || '',
})));
setIsEditMode(true);
@@ -651,7 +745,7 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
};
const addLine = () => {
setLines(prev => [...prev, { dc_type: 'debit', account_code: '', account_name: '', debit_amount: 0, credit_amount: 0, description: '' }]);
setLines(prev => [...prev, { dc_type: 'debit', account_code: '', account_name: '', debit_amount: 0, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' }]);
};
const removeLine = (idx) => {
@@ -746,8 +840,9 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
<tr className="bg-stone-100">
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-16">/</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">계정과목</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-36">차변금액</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-36">변금액</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">거래처</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-32">변금액</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-32">대변금액</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-10"></th>
</tr>
</thead>
@@ -773,6 +868,15 @@ className={`px-2 py-0.5 rounded text-xs font-medium cursor-pointer hover:opacity
accountCodes={accountCodes}
/>
</td>
<td className="px-3 py-2">
<TradingPartnerSelect
value={line.trading_partner_id}
valueName={line.trading_partner_name}
onChange={(id, name) => {
setLines(prev => prev.map((l, i) => i === idx ? { ...l, trading_partner_id: id, trading_partner_name: name } : l));
}}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
@@ -804,7 +908,7 @@ className="text-red-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-n
))}
{/* 합계 */}
<tr className={`font-bold ${isBalanced ? 'bg-green-50' : 'bg-red-50'}`}>
<td colSpan="2" className="px-3 py-2 text-center text-sm">합계</td>
<td colSpan="3" className="px-3 py-2 text-center text-sm">합계</td>
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalDebit)}</td>
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalCredit)}</td>
<td></td>