feat:신분증/통장사본 첨부파일 기능 추가

- SalesProspect 모델: id_card_image, bankbook_image 필드 추가
- hasIdCard(), hasBankbook() 메서드 및 URL 접근자 추가
- SalesProspectController: store/update/destroy에 처리 로직 추가
- create.blade.php: 드래그앤드롭 업로드 UI 추가
- edit.blade.php: 기존 이미지 표시 및 교체 UI 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-27 23:42:31 +09:00
parent e9f77cb5f1
commit 9f49501d33
4 changed files with 213 additions and 10 deletions

View File

@@ -94,13 +94,27 @@ public function store(Request $request)
'address' => 'nullable|string|max:500',
'status' => 'required|in:lead,prospect,negotiation,contracted,lost',
'business_card_image_data' => 'nullable|string',
'id_card_image_data' => 'nullable|string',
'bankbook_image_data' => 'nullable|string',
]);
// 명함 이미지 저장 (Base64)
if (!empty($validated['business_card_image_data'])) {
$validated['business_card_image'] = $this->saveBusinessCardImage($validated['business_card_image_data']);
unset($validated['business_card_image_data']);
$validated['business_card_image'] = $this->saveBase64Image($validated['business_card_image_data'], 'business-cards');
}
unset($validated['business_card_image_data']);
// 신분증 이미지 저장 (Base64)
if (!empty($validated['id_card_image_data'])) {
$validated['id_card_image'] = $this->saveBase64Image($validated['id_card_image_data'], 'id-cards');
}
unset($validated['id_card_image_data']);
// 통장사본 이미지 저장 (Base64)
if (!empty($validated['bankbook_image_data'])) {
$validated['bankbook_image'] = $this->saveBase64Image($validated['bankbook_image_data'], 'bankbooks');
}
unset($validated['bankbook_image_data']);
$prospect = SalesProspect::create($validated);
@@ -157,20 +171,40 @@ public function update(Request $request, int $id)
'address' => 'nullable|string|max:500',
'status' => 'required|in:lead,prospect,negotiation,contracted,lost',
'business_card' => 'nullable|image|max:5120',
'id_card' => 'nullable|image|max:5120',
'bankbook' => 'nullable|image|max:5120',
]);
// 명함 이미지 업로드 처리
if ($request->hasFile('business_card')) {
// 기존 이미지 삭제
if ($prospect->business_card_image) {
Storage::disk('public')->delete($prospect->business_card_image);
}
$validated['business_card_image'] = $request->file('business_card')
->store('business-cards', 'public');
}
unset($validated['business_card']);
// 신분증 이미지 업로드 처리
if ($request->hasFile('id_card')) {
if ($prospect->id_card_image) {
Storage::disk('public')->delete($prospect->id_card_image);
}
$validated['id_card_image'] = $request->file('id_card')
->store('id-cards', 'public');
}
unset($validated['id_card']);
// 통장사본 이미지 업로드 처리
if ($request->hasFile('bankbook')) {
if ($prospect->bankbook_image) {
Storage::disk('public')->delete($prospect->bankbook_image);
}
$validated['bankbook_image'] = $request->file('bankbook')
->store('bankbooks', 'public');
}
unset($validated['bankbook']);
$prospect->update($validated);
return redirect()->route('sales.prospects.show', $prospect->id)
@@ -184,9 +218,12 @@ public function destroy(int $id)
{
$prospect = SalesProspect::findOrFail($id);
// 명함 이미지 삭제
if ($prospect->business_card_image) {
Storage::disk('public')->delete($prospect->business_card_image);
// 첨부 이미지 삭제
$imageFields = ['business_card_image', 'id_card_image', 'bankbook_image'];
foreach ($imageFields as $field) {
if ($prospect->$field) {
Storage::disk('public')->delete($prospect->$field);
}
}
$prospect->delete();
@@ -196,9 +233,9 @@ public function destroy(int $id)
}
/**
* Base64 명함 이미지 저장
* Base64 이미지 저장
*/
private function saveBusinessCardImage(string $base64Data): ?string
private function saveBase64Image(string $base64Data, string $folder): ?string
{
// data:image/jpeg;base64,... 형식에서 데이터 추출
if (preg_match('/^data:image\/(\w+);base64,/', $base64Data, $matches)) {
@@ -213,7 +250,7 @@ private function saveBusinessCardImage(string $base64Data): ?string
return null;
}
$filename = 'business-cards/' . date('Ymd') . '_' . uniqid() . '.' . $extension;
$filename = $folder . '/' . date('Ymd') . '_' . uniqid() . '.' . $extension;
Storage::disk('public')->put($filename, $imageData);
return $filename;

View File

@@ -23,6 +23,8 @@ class SalesProspect extends Model
'email',
'address',
'business_card_image',
'id_card_image',
'bankbook_image',
'status',
];
@@ -157,4 +159,44 @@ public function getBusinessCardUrlAttribute(): ?string
return asset('storage/' . $this->business_card_image);
}
/**
* 신분증 이미지 존재 여부
*/
public function hasIdCard(): bool
{
return !empty($this->id_card_image);
}
/**
* 신분증 이미지 URL
*/
public function getIdCardUrlAttribute(): ?string
{
if (!$this->hasIdCard()) {
return null;
}
return asset('storage/' . $this->id_card_image);
}
/**
* 통장사본 이미지 존재 여부
*/
public function hasBankbook(): bool
{
return !empty($this->bankbook_image);
}
/**
* 통장사본 이미지 URL
*/
public function getBankbookUrlAttribute(): ?string
{
if (!$this->hasBankbook()) {
return null;
}
return asset('storage/' . $this->bankbook_image);
}
}

View File

@@ -112,6 +112,8 @@
<form action="{{ route('sales.prospects.store') }}" method="POST" enctype="multipart/form-data" class="bg-white rounded-lg shadow-sm p-6 space-y-6">
@csrf
<input type="hidden" name="business_card_image_data" id="business_card_image_data" value="">
<input type="hidden" name="id_card_image_data" id="id_card_image_data" value="">
<input type="hidden" name="bankbook_image_data" id="bankbook_image_data" value="">
<!-- 사업자번호 (중복 체크) -->
<div>
@@ -175,6 +177,37 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<p class="mt-1 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
</div>
<!-- 추가 첨부파일 -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">추가 서류</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">신분증 사본</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center cursor-pointer hover:border-blue-400 transition" id="id-card-drop-zone">
<input type="file" name="id_card" id="id_card_file" accept="image/*" class="hidden">
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
<p class="text-sm text-gray-500">클릭하여 업로드</p>
</div>
<img id="id-card-preview" class="mt-2 max-h-32 rounded-lg hidden" alt="신분증 미리보기">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">통장 사본</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center cursor-pointer hover:border-blue-400 transition" id="bankbook-drop-zone">
<input type="file" name="bankbook" id="bankbook_file" accept="image/*" class="hidden">
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<p class="text-sm text-gray-500">클릭하여 업로드</p>
</div>
<img id="bankbook-preview" class="mt-2 max-h-32 rounded-lg hidden" alt="통장사본 미리보기">
</div>
</div>
<p class="mt-2 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">메모</label>
<textarea name="memo" rows="3"
@@ -380,6 +413,66 @@ function showStatus(type, text) {
resultEl.className = 'mt-1 text-sm text-red-500';
});
});
// 신분증 업로드 처리
const idCardDropZone = document.getElementById('id-card-drop-zone');
const idCardFile = document.getElementById('id_card_file');
const idCardPreview = document.getElementById('id-card-preview');
idCardDropZone.addEventListener('click', () => idCardFile.click());
idCardDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
idCardDropZone.classList.add('border-blue-400', 'bg-blue-50');
});
idCardDropZone.addEventListener('dragleave', () => {
idCardDropZone.classList.remove('border-blue-400', 'bg-blue-50');
});
idCardDropZone.addEventListener('drop', (e) => {
e.preventDefault();
idCardDropZone.classList.remove('border-blue-400', 'bg-blue-50');
if (e.dataTransfer.files.length) handleIdCardFile(e.dataTransfer.files[0]);
});
idCardFile.addEventListener('change', (e) => {
if (e.target.files.length) handleIdCardFile(e.target.files[0]);
});
async function handleIdCardFile(file) {
if (!file.type.startsWith('image/')) return;
const base64 = await fileToBase64(file);
idCardPreview.src = base64;
idCardPreview.classList.remove('hidden');
document.getElementById('id_card_image_data').value = base64;
}
// 통장사본 업로드 처리
const bankbookDropZone = document.getElementById('bankbook-drop-zone');
const bankbookFile = document.getElementById('bankbook_file');
const bankbookPreview = document.getElementById('bankbook-preview');
bankbookDropZone.addEventListener('click', () => bankbookFile.click());
bankbookDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
bankbookDropZone.classList.add('border-blue-400', 'bg-blue-50');
});
bankbookDropZone.addEventListener('dragleave', () => {
bankbookDropZone.classList.remove('border-blue-400', 'bg-blue-50');
});
bankbookDropZone.addEventListener('drop', (e) => {
e.preventDefault();
bankbookDropZone.classList.remove('border-blue-400', 'bg-blue-50');
if (e.dataTransfer.files.length) handleBankbookFile(e.dataTransfer.files[0]);
});
bankbookFile.addEventListener('change', (e) => {
if (e.target.files.length) handleBankbookFile(e.target.files[0]);
});
async function handleBankbookFile(file) {
if (!file.type.startsWith('image/')) return;
const base64 = await fileToBase64(file);
bankbookPreview.src = base64;
bankbookPreview.classList.remove('hidden');
document.getElementById('bankbook_image_data').value = base64;
}
});
</script>
@endpush

View File

@@ -86,6 +86,37 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<p class="mt-1 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
</div>
<!-- 추가 첨부파일 -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">추가 서류</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">신분증 사본</label>
@if($prospect->hasIdCard())
<div class="mb-2 p-2 bg-gray-50 rounded-lg">
<img src="{{ $prospect->id_card_url }}" alt="현재 신분증" class="max-h-32 rounded">
<p class="text-xs text-gray-500 mt-1"> 이미지를 업로드하면 기존 이미지가 교체됩니다</p>
</div>
@endif
<input type="file" name="id_card" accept="image/*"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">통장 사본</label>
@if($prospect->hasBankbook())
<div class="mb-2 p-2 bg-gray-50 rounded-lg">
<img src="{{ $prospect->bankbook_url }}" alt="현재 통장사본" class="max-h-32 rounded">
<p class="text-xs text-gray-500 mt-1"> 이미지를 업로드하면 기존 이미지가 교체됩니다</p>
</div>
@endif
<input type="file" name="bankbook" accept="image/*"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<p class="mt-2 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">메모</label>
<textarea name="memo" rows="3"