feat:일반전표입력에 계정과목 설정 기능 이관

계좌입출금내역에서 제거된 계정과목 설정 기능을 일반전표입력 페이지로 이관
- JournalEntryController에 계정과목 CRUD 메서드 추가
- 계정과목 CRUD 라우트 추가 (journal-entries/account-codes/*)
- AccountCodeSettingsModal 컴포넌트 추가
- 페이지 헤더에 계정과목 설정 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-11 09:33:33 +09:00
parent d18d63f483
commit 8e135672a1
3 changed files with 296 additions and 3 deletions

View File

@@ -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' => '계정과목이 삭제되었습니다.',
]);
}
}

View File

@@ -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">&times;</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>
);
}

View File

@@ -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');
// 은행거래 기반 분개