feat:법인카드 서버 저장 기능 구현

- CorporateCard 모델 추가
- CorporateCardController API 추가 (CRUD)
- 라우트 추가 (list, store, update, deactivate, destroy)
- React 컴포넌트 API 연동 (fetch 호출)
- 로딩 상태 UI 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-30 19:12:07 +09:00
parent 34752f0d64
commit 10ec26723f
4 changed files with 400 additions and 50 deletions

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\CorporateCard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CorporateCardController extends Controller
{
/**
* 카드 목록 조회
*/
public function index(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$cards = CorporateCard::forTenant($tenantId)
->orderBy('created_at', 'desc')
->get()
->map(function ($card) {
return [
'id' => $card->id,
'cardName' => $card->card_name,
'cardCompany' => $card->card_company,
'cardNumber' => $card->card_number,
'cardType' => $card->card_type,
'paymentDay' => $card->payment_day,
'creditLimit' => (float) $card->credit_limit,
'currentUsage' => (float) $card->current_usage,
'cardHolderName' => $card->card_holder_name,
'actualUser' => $card->actual_user,
'expiryDate' => $card->expiry_date,
'cvc' => $card->cvc,
'status' => $card->status,
'memo' => $card->memo,
];
});
return response()->json([
'success' => true,
'data' => $cards,
]);
}
/**
* 카드 등록
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'cardName' => 'required|string|max:100',
'cardCompany' => 'required|string|max:50',
'cardNumber' => 'required|string|max:30',
'cardType' => 'required|in:credit,debit',
'cardHolderName' => 'required|string|max:100',
'actualUser' => 'required|string|max:100',
]);
$tenantId = session('selected_tenant_id', 1);
$card = CorporateCard::create([
'tenant_id' => $tenantId,
'card_name' => $request->input('cardName'),
'card_company' => $request->input('cardCompany'),
'card_number' => $request->input('cardNumber'),
'card_type' => $request->input('cardType'),
'payment_day' => $request->input('paymentDay', 15),
'credit_limit' => $request->input('creditLimit', 0),
'current_usage' => 0,
'card_holder_name' => $request->input('cardHolderName'),
'actual_user' => $request->input('actualUser'),
'expiry_date' => $request->input('expiryDate'),
'cvc' => $request->input('cvc'),
'status' => $request->input('status', 'active'),
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '카드가 등록되었습니다.',
'data' => [
'id' => $card->id,
'cardName' => $card->card_name,
'cardCompany' => $card->card_company,
'cardNumber' => $card->card_number,
'cardType' => $card->card_type,
'paymentDay' => $card->payment_day,
'creditLimit' => (float) $card->credit_limit,
'currentUsage' => (float) $card->current_usage,
'cardHolderName' => $card->card_holder_name,
'actualUser' => $card->actual_user,
'expiryDate' => $card->expiry_date,
'cvc' => $card->cvc,
'status' => $card->status,
'memo' => $card->memo,
],
]);
}
/**
* 카드 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$card = CorporateCard::findOrFail($id);
$request->validate([
'cardName' => 'required|string|max:100',
'cardCompany' => 'required|string|max:50',
'cardNumber' => 'required|string|max:30',
'cardType' => 'required|in:credit,debit',
'cardHolderName' => 'required|string|max:100',
'actualUser' => 'required|string|max:100',
]);
$card->update([
'card_name' => $request->input('cardName'),
'card_company' => $request->input('cardCompany'),
'card_number' => $request->input('cardNumber'),
'card_type' => $request->input('cardType'),
'payment_day' => $request->input('paymentDay', 15),
'credit_limit' => $request->input('creditLimit', 0),
'card_holder_name' => $request->input('cardHolderName'),
'actual_user' => $request->input('actualUser'),
'expiry_date' => $request->input('expiryDate'),
'cvc' => $request->input('cvc'),
'status' => $request->input('status', 'active'),
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '카드가 수정되었습니다.',
'data' => [
'id' => $card->id,
'cardName' => $card->card_name,
'cardCompany' => $card->card_company,
'cardNumber' => $card->card_number,
'cardType' => $card->card_type,
'paymentDay' => $card->payment_day,
'creditLimit' => (float) $card->credit_limit,
'currentUsage' => (float) $card->current_usage,
'cardHolderName' => $card->card_holder_name,
'actualUser' => $card->actual_user,
'expiryDate' => $card->expiry_date,
'cvc' => $card->cvc,
'status' => $card->status,
'memo' => $card->memo,
],
]);
}
/**
* 카드 비활성화
*/
public function deactivate(int $id): JsonResponse
{
$card = CorporateCard::findOrFail($id);
$card->update(['status' => 'inactive']);
return response()->json([
'success' => true,
'message' => '카드가 비활성화되었습니다.',
]);
}
/**
* 카드 영구삭제
*/
public function destroy(int $id): JsonResponse
{
$card = CorporateCard::findOrFail($id);
$card->forceDelete();
return response()->json([
'success' => true,
'message' => '카드가 영구 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models\Finance;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CorporateCard extends Model
{
use SoftDeletes;
protected $table = 'corporate_cards';
protected $fillable = [
'tenant_id',
'card_name',
'card_company',
'card_number',
'card_type',
'payment_day',
'credit_limit',
'current_usage',
'card_holder_name',
'actual_user',
'expiry_date',
'cvc',
'status',
'memo',
];
protected $casts = [
'payment_day' => 'integer',
'credit_limit' => 'decimal:2',
'current_usage' => 'decimal:2',
];
/**
* 활성 카드만 조회
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
/**
* 특정 테넌트의 카드 조회
*/
public function scopeForTenant($query, $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
}

View File

@@ -51,8 +51,9 @@
const Zap = createIcon('zap');
function CorporateCardsManagement() {
// 카드 목록 데이터 (빈 배열로 시작 - 실제 데이터는 서버 연동 후 로드)
// 카드 목록 데이터
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
@@ -60,6 +61,9 @@ function CorporateCardsManagement() {
const [modalMode, setModalMode] = useState('add'); // 'add' or 'edit'
const [editingCard, setEditingCard] = useState(null);
// CSRF 토큰
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
// 새 카드 폼 초기값
const initialFormState = {
cardName: '',
@@ -77,8 +81,28 @@ function CorporateCardsManagement() {
};
const [formData, setFormData] = useState(initialFormState);
// 초기 데이터 로드
useEffect(() => {
fetchCards();
}, []);
const fetchCards = async () => {
try {
setLoading(true);
const response = await fetch('/finance/corporate-cards/list');
const result = await response.json();
if (result.success) {
setCards(result.data);
}
} catch (error) {
console.error('카드 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
// 테스트용 임시 데이터 생성
const generateTestData = () => {
const generateTestData = async () => {
const companies = ['삼성카드', '현대카드', '국민카드', '신한카드', '롯데카드'];
const names = ['업무용', '마케팅', '개발팀', '영업팀', '관리팀'];
const users = ['김철수', '이영희', '박민수', '최지영', '정대한'];
@@ -87,24 +111,40 @@ function CorporateCardsManagement() {
const randomCard = () => `${randomNum(1000,9999)}-${randomNum(1000,9999)}-${randomNum(1000,9999)}-${randomNum(1000,9999)}`;
const randomExpiry = () => `${randomNum(25,30)}/${String(randomNum(1,12)).padStart(2,'0')}`;
const newCards = Array.from({ length: 3 }, (_, i) => ({
id: Date.now() + i,
cardName: `${names[randomNum(0,4)]} 법인카드`,
cardCompany: companies[randomNum(0,4)],
cardNumber: randomCard(),
cardType: Math.random() > 0.3 ? 'credit' : 'debit',
paymentDay: [10, 15, 20, 25][randomNum(0,3)],
creditLimit: randomNum(3, 20) * 1000000,
currentUsage: randomNum(0, 10) * 100000,
cardHolderName: '(주)테스트회사',
actualUser: users[randomNum(0,4)],
expiryDate: randomExpiry(),
cvc: String(randomNum(100,999)),
status: 'active',
memo: '테스트 데이터'
}));
// 3개의 테스트 카드를 서버에 저장
for (let i = 0; i < 3; i++) {
const testCard = {
cardName: `${names[randomNum(0,4)]} 법인카드`,
cardCompany: companies[randomNum(0,4)],
cardNumber: randomCard(),
cardType: Math.random() > 0.3 ? 'credit' : 'debit',
paymentDay: [10, 15, 20, 25][randomNum(0,3)],
creditLimit: randomNum(3, 20) * 1000000,
cardHolderName: '(주)테스트회사',
actualUser: users[randomNum(0,4)],
expiryDate: randomExpiry(),
cvc: String(randomNum(100,999)),
status: 'active',
memo: '테스트 데이터'
};
setCards(prev => [...prev, ...newCards]);
try {
const response = await fetch('/finance/corporate-cards/store', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify(testCard),
});
const result = await response.json();
if (result.success) {
setCards(prev => [...prev, result.data]);
}
} catch (error) {
console.error('테스트 데이터 생성 실패:', error);
}
}
};
// 카드사 목록
@@ -174,52 +214,111 @@ function CorporateCardsManagement() {
};
// 카드 저장
const handleSaveCard = () => {
const handleSaveCard = async () => {
if (!formData.cardName || !formData.cardNumber || !formData.cardHolderName || !formData.actualUser) {
alert('필수 항목을 입력해주세요.');
return;
}
if (modalMode === 'add') {
const newCard = {
id: Date.now(),
...formData,
creditLimit: parseInt(formData.creditLimit) || 0,
currentUsage: 0
};
setCards(prev => [...prev, newCard]);
} else {
setCards(prev => prev.map(card =>
card.id === editingCard.id
? { ...card, ...formData, creditLimit: parseInt(formData.creditLimit) || 0 }
: card
));
}
try {
if (modalMode === 'add') {
const response = await fetch('/finance/corporate-cards/store', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({
...formData,
creditLimit: parseInt(formData.creditLimit) || 0,
}),
});
const result = await response.json();
if (result.success) {
setCards(prev => [...prev, result.data]);
} else {
alert(result.message || '저장에 실패했습니다.');
return;
}
} else {
const response = await fetch(`/finance/corporate-cards/${editingCard.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({
...formData,
creditLimit: parseInt(formData.creditLimit) || 0,
}),
});
const result = await response.json();
if (result.success) {
setCards(prev => prev.map(card =>
card.id === editingCard.id ? result.data : card
));
} else {
alert(result.message || '수정에 실패했습니다.');
return;
}
}
setShowModal(false);
setEditingCard(null);
setShowModal(false);
setEditingCard(null);
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
// 카드 비활성화 (소프트 삭제)
const handleDeactivateCard = (id) => {
const handleDeactivateCard = async (id) => {
if (confirm('카드를 비활성화하시겠습니까?\n(목록에서 숨겨지지만 데이터는 유지됩니다)')) {
setCards(prev => prev.map(card =>
card.id === id ? { ...card, status: 'inactive' } : card
));
if (showModal) {
setShowModal(false);
setEditingCard(null);
try {
const response = await fetch(`/finance/corporate-cards/${id}/deactivate`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
},
});
const result = await response.json();
if (result.success) {
setCards(prev => prev.map(card =>
card.id === id ? { ...card, status: 'inactive' } : card
));
if (showModal) {
setShowModal(false);
setEditingCard(null);
}
}
} catch (error) {
console.error('비활성화 실패:', error);
alert('비활성화 중 오류가 발생했습니다.');
}
}
};
// 카드 영구삭제
const handlePermanentDeleteCard = (id) => {
const handlePermanentDeleteCard = async (id) => {
if (confirm('⚠️ 카드를 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.')) {
setCards(prev => prev.filter(card => card.id !== id));
if (showModal) {
setShowModal(false);
setEditingCard(null);
try {
const response = await fetch(`/finance/corporate-cards/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken,
},
});
const result = await response.json();
if (result.success) {
setCards(prev => prev.filter(card => card.id !== id));
if (showModal) {
setShowModal(false);
setEditingCard(null);
}
}
} catch (error) {
console.error('삭제 실패:', error);
alert('삭제 중 오류가 발생했습니다.');
}
}
};
@@ -426,7 +525,14 @@ className={`h-1.5 rounded-full ${getUsageColor(getUsagePercent(card.currentUsage
</div>
))}
{filteredCards.length === 0 && (
{loading && (
<div className="text-center py-12 text-gray-400">
<div className="animate-spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p>카드 목록을 불러오는 ...</p>
</div>
)}
{!loading && filteredCards.length === 0 && (
<div className="text-center py-12 text-gray-400">
<CreditCard className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>등록된 카드가 없습니다.</p>

View File

@@ -690,6 +690,16 @@
return view('finance.corporate-cards');
})->name('corporate-cards');
// 법인카드 API
Route::prefix('corporate-cards')->name('corporate-cards.')->group(function () {
Route::get('/list', [\App\Http\Controllers\Finance\CorporateCardController::class, 'index'])->name('list');
Route::post('/store', [\App\Http\Controllers\Finance\CorporateCardController::class, 'store'])->name('store');
Route::put('/{id}', [\App\Http\Controllers\Finance\CorporateCardController::class, 'update'])->name('update');
Route::post('/{id}/deactivate', [\App\Http\Controllers\Finance\CorporateCardController::class, 'deactivate'])->name('deactivate');
Route::delete('/{id}', [\App\Http\Controllers\Finance\CorporateCardController::class, 'destroy'])->name('destroy');
});
Route::get('/card-transactions', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.card-transactions'));