feat:일반전표입력에 계정과목 설정 기능 이관
계좌입출금내역에서 제거된 계정과목 설정 기능을 일반전표입력 페이지로 이관 - JournalEntryController에 계정과목 CRUD 메서드 추가 - 계정과목 CRUD 라우트 추가 (journal-entries/account-codes/*) - AccountCodeSettingsModal 컴포넌트 추가 - 페이지 헤더에 계정과목 설정 버튼 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -644,4 +644,103 @@ public function deleteBankJournal(int $id): JsonResponse
|
||||
'message' => '분개가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 전체 목록 (활성/비활성 포함)
|
||||
*/
|
||||
public function accountCodesAll(): JsonResponse
|
||||
{
|
||||
$codes = AccountCode::getAll();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $codes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 추가
|
||||
*/
|
||||
public function accountCodeStore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:10',
|
||||
'name' => 'required|string|max:100',
|
||||
'category' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
if (AccountCode::where('code', $validated['code'])->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '이미 존재하는 계정과목 코드입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$maxSort = AccountCode::max('sort_order') ?? 0;
|
||||
|
||||
$accountCode = AccountCode::create([
|
||||
'tenant_id' => 1,
|
||||
'code' => $validated['code'],
|
||||
'name' => $validated['name'],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'sort_order' => $maxSort + 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계정과목이 추가되었습니다.',
|
||||
'data' => $accountCode,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 수정
|
||||
*/
|
||||
public function accountCodeUpdate(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$accountCode = AccountCode::find($id);
|
||||
if (!$accountCode) {
|
||||
return response()->json(['success' => false, 'error' => '계정과목을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|string|max:10',
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'is_active' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
if (isset($validated['code']) && $validated['code'] !== $accountCode->code) {
|
||||
if (AccountCode::where('code', $validated['code'])->where('id', '!=', $id)->exists()) {
|
||||
return response()->json(['success' => false, 'error' => '이미 존재하는 계정과목 코드입니다.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$accountCode->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계정과목이 수정되었습니다.',
|
||||
'data' => $accountCode,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 삭제
|
||||
*/
|
||||
public function accountCodeDestroy(int $id): JsonResponse
|
||||
{
|
||||
$accountCode = AccountCode::find($id);
|
||||
if (!$accountCode) {
|
||||
return response()->json(['success' => false, 'error' => '계정과목을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$accountCode->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계정과목이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
const ArrowDownCircle = createIcon('arrow-down-circle');
|
||||
const ArrowUpCircle = createIcon('arrow-up-circle');
|
||||
const RefreshCw = createIcon('refresh-cw');
|
||||
const Settings = createIcon('settings');
|
||||
|
||||
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
@@ -187,6 +188,179 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${index === highlightIndex ? 'bg-
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// AccountCodeSettingsModal (계정과목 설정)
|
||||
// ============================================================
|
||||
const AccountCodeSettingsModal = ({ isOpen, onClose, onUpdate }) => {
|
||||
const [codes, setCodes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
|
||||
const categories = ['자산', '부채', '자본', '수익', '비용'];
|
||||
|
||||
useEffect(() => { if (isOpen) loadCodes(); }, [isOpen]);
|
||||
|
||||
const loadCodes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/finance/journal-entries/account-codes/all');
|
||||
const data = await res.json();
|
||||
if (data.success) setCodes(data.data || []);
|
||||
} catch (err) {
|
||||
notify('계정과목 로드 실패', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newCode.trim() || !newName.trim()) { notify('코드와 이름을 입력해주세요.', 'warning'); return; }
|
||||
try {
|
||||
const res = await fetch('/finance/journal-entries/account-codes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify({ code: newCode.trim(), name: newName.trim(), category: newCategory || null }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
notify('계정과목이 추가되었습니다.', 'success');
|
||||
setNewCode(''); setNewName(''); setNewCategory('');
|
||||
loadCodes(); onUpdate();
|
||||
} else {
|
||||
notify(data.error || '추가 실패', 'error');
|
||||
}
|
||||
} catch (err) { notify('추가 실패: ' + err.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleToggleActive = async (item) => {
|
||||
try {
|
||||
const res = await fetch(`/finance/journal-entries/account-codes/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
body: JSON.stringify({ is_active: !item.is_active }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { loadCodes(); onUpdate(); }
|
||||
} catch (err) { notify('변경 실패', 'error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (item) => {
|
||||
if (!confirm(`"${item.code} ${item.name}" 계정과목을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/finance/journal-entries/account-codes/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { notify('삭제되었습니다.', 'success'); loadCodes(); onUpdate(); }
|
||||
else { notify(data.error || '삭제 실패', 'error'); }
|
||||
} catch (err) { notify('삭제 실패: ' + err.message, 'error'); }
|
||||
};
|
||||
|
||||
const filteredCodes = codes.filter(c => {
|
||||
const matchText = filter === '' || c.code.includes(filter) || c.name.includes(filter);
|
||||
const matchCategory = categoryFilter === '' || c.category === categoryFilter;
|
||||
return matchText && matchCategory;
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200 bg-stone-50">
|
||||
<h2 className="text-lg font-bold text-stone-900">계정과목 설정</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-2xl">×</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-b border-stone-100 bg-emerald-50/50">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">코드</label>
|
||||
<input type="text" value={newCode} onChange={(e) => setNewCode(e.target.value)} placeholder="예: 101"
|
||||
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
</div>
|
||||
<div className="flex-[2]">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">계정과목명</label>
|
||||
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="예: 현금"
|
||||
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">분류</label>
|
||||
<select value={newCategory} onChange={(e) => setNewCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
<option value="">선택</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleAdd} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-b border-stone-100 flex gap-3">
|
||||
<input type="text" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="코드 또는 이름 검색..."
|
||||
className="flex-1 px-3 py-2 border border-stone-200 rounded-lg text-sm" />
|
||||
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-stone-200 rounded-lg text-sm">
|
||||
<option value="">전체 분류</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
<span className="text-sm text-stone-500 py-2">{filteredCodes.length}개</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={ {maxHeight: '400px'} }>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-stone-400">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-stone-600">코드</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-stone-600">계정과목명</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-stone-600">분류</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-stone-600">상태</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-stone-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{filteredCodes.map(item => (
|
||||
<tr key={item.id} className={`hover:bg-stone-50 ${!item.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-4 py-2 font-mono">{item.code}</td>
|
||||
<td className="px-4 py-2">{item.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
item.category === '자산' ? 'bg-blue-100 text-blue-700' :
|
||||
item.category === '부채' ? 'bg-red-100 text-red-700' :
|
||||
item.category === '자본' ? 'bg-purple-100 text-purple-700' :
|
||||
item.category === '수익' ? 'bg-green-100 text-green-700' :
|
||||
item.category === '비용' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-stone-100 text-stone-600'
|
||||
}`}>{item.category || '-'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button onClick={() => handleToggleActive(item)}
|
||||
className={`px-2 py-1 rounded text-xs ${item.is_active ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-stone-100 text-stone-500 hover:bg-stone-200'}`}>
|
||||
{item.is_active ? '사용중' : '미사용'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button onClick={() => handleDelete(item)} className="text-red-500 hover:text-red-700 text-xs">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-stone-200 bg-stone-50 flex justify-end">
|
||||
<button onClick={onClose} className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font-medium">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// AddTradingPartnerModal
|
||||
// ============================================================
|
||||
@@ -1042,6 +1216,7 @@ className={`px-6 py-2 text-sm font-medium rounded-lg flex items-center gap-1 tra
|
||||
function App() {
|
||||
const [accountCodes, setAccountCodes] = useState([]);
|
||||
const [tradingPartners, setTradingPartners] = useState([]);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
|
||||
const fetchMasterData = async () => {
|
||||
try {
|
||||
@@ -1067,9 +1242,18 @@ function App() {
|
||||
return (
|
||||
<div>
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-stone-800">일반전표입력</h1>
|
||||
<p className="text-sm text-stone-500 mt-1">계좌입출금내역을 기반으로 분개 전표를 생성합니다</p>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-800">일반전표입력</h1>
|
||||
<p className="text-sm text-stone-500 mt-1">계좌입출금내역을 기반으로 분개 전표를 생성합니다</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-stone-700 bg-white border border-stone-300 rounded-lg hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
계정과목 설정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BankTransactionTab
|
||||
@@ -1077,6 +1261,12 @@ function App() {
|
||||
tradingPartners={tradingPartners}
|
||||
onPartnerAdded={handlePartnerAdded}
|
||||
/>
|
||||
|
||||
<AccountCodeSettingsModal
|
||||
isOpen={showSettingsModal}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
onUpdate={fetchMasterData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -856,6 +856,10 @@
|
||||
Route::get('/list', [\App\Http\Controllers\Finance\JournalEntryController::class, 'index'])->name('list');
|
||||
Route::get('/next-entry-no', [\App\Http\Controllers\Finance\JournalEntryController::class, 'nextEntryNo'])->name('next-entry-no');
|
||||
Route::get('/account-codes', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodes'])->name('account-codes');
|
||||
Route::get('/account-codes/all', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodesAll'])->name('account-codes.all');
|
||||
Route::post('/account-codes', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodeStore'])->name('account-codes.store');
|
||||
Route::put('/account-codes/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodeUpdate'])->name('account-codes.update');
|
||||
Route::delete('/account-codes/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodeDestroy'])->name('account-codes.destroy');
|
||||
Route::get('/trading-partners', [\App\Http\Controllers\Finance\JournalEntryController::class, 'tradingPartners'])->name('trading-partners');
|
||||
|
||||
// 은행거래 기반 분개
|
||||
|
||||
Reference in New Issue
Block a user