feat:계약 대시보드 선택 삭제 기능 추가
- EsignApiController에 destroy 메서드 추가 (복수 삭제 지원) - 관련 파일(PDF, 서명이미지) 및 레코드(서명자, 필드, 감사로그) 일괄 삭제 - 서명 진행 중(pending, partially_signed) 계약은 삭제 차단 - DELETE /esign/contracts/destroy 라우트 추가 - 대시보드에 체크박스 전체/개별 선택 + 삭제 버튼 UI 추가 - 삭제 전 confirm 확인 다이얼로그 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,66 @@ public function cancel(Request $request, int $id): JsonResponse
|
||||
return response()->json(['success' => true, 'message' => '계약이 취소되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 삭제 (단건/복수)
|
||||
*/
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$ids = $request->input('ids');
|
||||
|
||||
$contracts = EsignContract::forTenant($tenantId)->whereIn('id', $ids)->get();
|
||||
|
||||
if ($contracts->isEmpty()) {
|
||||
return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
// 진행 중인 계약(pending, partially_signed) 차단
|
||||
$activeContracts = $contracts->filter(fn ($c) => in_array($c->status, ['pending', 'partially_signed']));
|
||||
if ($activeContracts->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
foreach ($contracts as $contract) {
|
||||
// 관련 파일 삭제
|
||||
if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) {
|
||||
Storage::disk('local')->delete($contract->original_file_path);
|
||||
}
|
||||
if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) {
|
||||
Storage::disk('local')->delete($contract->signed_file_path);
|
||||
}
|
||||
|
||||
// 서명 이미지 파일 삭제
|
||||
$signers = EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->get();
|
||||
foreach ($signers as $signer) {
|
||||
if ($signer->signature_image_path && Storage::disk('local')->exists($signer->signature_image_path)) {
|
||||
Storage::disk('local')->delete($signer->signature_image_path);
|
||||
}
|
||||
}
|
||||
|
||||
// 관련 레코드 삭제
|
||||
EsignSignField::where('contract_id', $contract->id)->delete();
|
||||
EsignAuditLog::where('contract_id', $contract->id)->delete();
|
||||
EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->delete();
|
||||
$contract->delete();
|
||||
$deletedCount++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$deletedCount}건의 계약이 삭제되었습니다.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 위치 설정
|
||||
*/
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
const [filter, setFilter] = useState({ status: '', search: '' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [pagination, setPagination] = useState({});
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
@@ -74,8 +76,56 @@
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
setSelected(new Set());
|
||||
}, [filter, page]);
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === contracts.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(contracts.map(c => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selected.size === 0) return;
|
||||
|
||||
const activeIds = contracts.filter(c => selected.has(c.id) && ['pending', 'partially_signed'].includes(c.status)).map(c => c.id);
|
||||
if (activeIds.length > 0) {
|
||||
alert('서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`선택한 ${selected.size}건의 계약을 삭제하시겠습니까?\n\n삭제된 계약은 복구할 수 없습니다.`)) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch('/esign/contracts/destroy', {
|
||||
method: 'DELETE',
|
||||
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: [...selected] }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
fetchContracts();
|
||||
fetchStats();
|
||||
} else {
|
||||
alert(json.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||||
useEffect(() => { fetchContracts(); }, [fetchContracts]);
|
||||
|
||||
@@ -102,7 +152,7 @@ className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<select value={filter.status} onChange={e => { setFilter(f => ({...f, status: e.target.value})); setPage(1); }}
|
||||
className="border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">전체 상태</option>
|
||||
@@ -111,6 +161,15 @@ className="border rounded-lg px-3 py-2 text-sm">
|
||||
<input type="text" placeholder="제목 또는 코드 검색..." value={filter.search}
|
||||
onChange={e => { setFilter(f => ({...f, search: e.target.value})); setPage(1); }}
|
||||
className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" />
|
||||
{selected.size > 0 && (
|
||||
<button onClick={handleDelete} disabled={deleting}
|
||||
className="ml-auto inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
</svg>
|
||||
{deleting ? '삭제 중...' : `${selected.size}건 삭제`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
@@ -118,6 +177,10 @@ className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" />
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-3 w-10">
|
||||
<input type="checkbox" checked={contracts.length > 0 && selected.size === contracts.length}
|
||||
onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">계약코드</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">서명자</th>
|
||||
@@ -128,14 +191,18 @@ className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" />
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr><td colSpan="6" className="px-4 py-8 text-center text-gray-400">로딩 중...</td></tr>
|
||||
<tr><td colSpan="7" className="px-4 py-8 text-center text-gray-400">로딩 중...</td></tr>
|
||||
) : contracts.length === 0 ? (
|
||||
<tr><td colSpan="6" className="px-4 py-8 text-center text-gray-400">계약이 없습니다.</td></tr>
|
||||
<tr><td colSpan="7" className="px-4 py-8 text-center text-gray-400">계약이 없습니다.</td></tr>
|
||||
) : contracts.map(c => (
|
||||
<tr key={c.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => location.href = `/esign/${c.id}`}>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-600">{c.contract_code}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{c.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
<tr key={c.id} className={`hover:bg-gray-50 cursor-pointer ${selected.has(c.id) ? 'bg-blue-50' : ''}`}>
|
||||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.has(c.id)} onChange={() => toggleSelect(c.id)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-600" onClick={() => location.href = `/esign/${c.id}`}>{c.contract_code}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900" onClick={() => location.href = `/esign/${c.id}`}>{c.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600" onClick={() => location.href = `/esign/${c.id}`}>
|
||||
{(c.signers || []).map(s => (
|
||||
<span key={s.id} className="inline-flex items-center mr-2">
|
||||
<span className={`w-2 h-2 rounded-full mr-1 ${s.status === 'signed' ? 'bg-green-500' : 'bg-gray-300'}`}></span>
|
||||
@@ -143,9 +210,9 @@ className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" />
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{c.created_at?.slice(0,10)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{c.expires_at?.slice(0,10)}</td>
|
||||
<td className="px-4 py-3" onClick={() => location.href = `/esign/${c.id}`}><StatusBadge status={c.status} /></td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500" onClick={() => location.href = `/esign/${c.id}`}>{c.created_at?.slice(0,10)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500" onClick={() => location.href = `/esign/${c.id}`}>{c.expires_at?.slice(0,10)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
Route::post('/store', [EsignApiController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [EsignApiController::class, 'show'])->whereNumber('id')->name('show');
|
||||
Route::post('/{id}/cancel', [EsignApiController::class, 'cancel'])->whereNumber('id')->name('cancel');
|
||||
Route::delete('/destroy', [EsignApiController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/fields', [EsignApiController::class, 'configureFields'])->whereNumber('id')->name('fields');
|
||||
Route::post('/{id}/send', [EsignApiController::class, 'send'])->whereNumber('id')->name('send');
|
||||
Route::post('/{id}/remind', [EsignApiController::class, 'remind'])->whereNumber('id')->name('remind');
|
||||
|
||||
Reference in New Issue
Block a user