feat: [esign] 전자계약 수정 기능 추가

- draft 상태 계약의 제목, 설명, 서명자 정보, 파일 수정 가능
- 계약 상세 페이지에 '계약 정보 수정' 버튼 추가
- create.blade.php를 생성/수정 겸용으로 확장
This commit is contained in:
김보곤
2026-03-11 11:55:46 +09:00
parent d7428e1785
commit edc69040ab
5 changed files with 186 additions and 18 deletions

View File

@@ -572,6 +572,97 @@ public function store(Request $request): JsonResponse
]);
}
/**
* 계약 수정 (draft 상태만)
*/
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$contract = EsignContract::forTenant($tenantId)->findOrFail($id);
if ($contract->status !== 'draft') {
return response()->json(['success' => false, 'message' => '초안 상태의 계약만 수정할 수 있습니다.'], 422);
}
$request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string',
'sign_order_type' => 'required|in:counterpart_first,creator_first',
'expires_at' => 'nullable|date',
'signers' => 'required|array|size:2',
'signers.*.name' => 'required|string|max:100',
'signers.*.email' => 'required|email|max:200',
'signers.*.phone' => 'nullable|string|max:20',
'signers.*.role' => 'required|in:creator,counterpart',
'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480',
]);
$userId = auth()->id();
// PDF 파일 교체
if ($request->hasFile('file')) {
// 기존 파일 삭제
if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) {
Storage::disk('local')->delete($contract->original_file_path);
}
$file = $request->file('file');
$converter = new DocxToPdfConverter;
$result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts");
$contract->original_file_path = $result['path'];
$contract->original_file_name = $result['name'];
$contract->original_file_hash = $result['hash'];
$contract->original_file_size = $result['size'];
}
$contract->title = $request->input('title');
$contract->description = $request->input('description');
$contract->sign_order_type = $request->input('sign_order_type');
$contract->expires_at = $request->input('expires_at')
? \Carbon\Carbon::parse($request->input('expires_at'))
: $contract->expires_at;
$contract->updated_by = $userId;
$contract->save();
// 서명자 정보 업데이트
$signers = $request->input('signers');
foreach ($signers as $signerData) {
$existingSigner = EsignSigner::withoutGlobalScopes()
->where('contract_id', $contract->id)
->where('role', $signerData['role'])
->first();
if ($existingSigner) {
$existingSigner->update([
'name' => $signerData['name'],
'email' => $signerData['email'],
'phone' => $signerData['phone'] ?? null,
'sign_order' => $signerData['role'] === 'creator'
? ($request->input('sign_order_type') === 'creator_first' ? 1 : 2)
: ($request->input('sign_order_type') === 'counterpart_first' ? 1 : 2),
]);
}
}
// 감사 로그
EsignAuditLog::create([
'tenant_id' => $tenantId,
'contract_id' => $contract->id,
'action' => 'contract_updated',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => ['updated_by' => $userId],
'created_at' => now(),
]);
return response()->json([
'success' => true,
'message' => '계약이 수정되었습니다.',
'data' => $contract->load('signers'),
]);
}
/**
* 계약 취소
*/

View File

@@ -27,6 +27,15 @@ public function create(Request $request): View|Response
return view('esign.create');
}
public function edit(Request $request, int $id): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('esign.edit', $id));
}
return view('esign.create', ['contractId' => $id]);
}
public function detail(Request $request, int $id): View|Response
{
if ($request->header('HX-Request')) {

View File

@@ -1,10 +1,10 @@
@extends('layouts.app')
@section('title', 'SAM E-Sign - 새 계약 생성')
@section('title', isset($contractId) ? 'SAM E-Sign - 계약 수정' : 'SAM E-Sign - 새 계약 생성')
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="esign-create-root"></div>
<div id="esign-create-root" data-contract-id="{{ $contractId ?? '' }}"></div>
@endsection
@push('scripts')
@@ -22,6 +22,8 @@
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const IS_ADMIN = window.__esignCreate?.isAdmin || false;
const EDIT_CONTRACT_ID = document.getElementById('esign-create-root')?.dataset.contractId || '';
const IS_EDIT = !!EDIT_CONTRACT_ID;
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'];
const FIELD_TYPE_INFO = {
@@ -456,6 +458,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
const [metadata, setMetadata] = useState({});
const [partnerModalOpen, setPartnerModalOpen] = useState(false);
const [tenantModalOpen, setTenantModalOpen] = useState(false);
const [editLoading, setEditLoading] = useState(IS_EDIT);
const [existingFileName, setExistingFileName] = useState('');
const fileRef = useRef(null);
const hasTemplates = templates.length > 0;
@@ -472,6 +476,46 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
.then(r => r.json())
.then(json => { if (json.success) setTemplates(json.data); })
.catch(() => {});
// 수정 모드: 기존 계약 데이터 로드
if (IS_EDIT) {
fetch(`/esign/contracts/${EDIT_CONTRACT_ID}`, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(json => {
if (json.success) {
const c = json.data;
if (c.status !== 'draft') {
alert('초안 상태의 계약만 수정할 수 있습니다.');
location.href = `/esign/${EDIT_CONTRACT_ID}`;
return;
}
const creator = (c.signers || []).find(s => s.role === 'creator') || {};
const counterpart = (c.signers || []).find(s => s.role === 'counterpart') || {};
const expiresAt = c.expires_at ? new Date(c.expires_at) : null;
const expiresStr = expiresAt
? expiresAt.getFullYear() + '-' + String(expiresAt.getMonth()+1).padStart(2,'0') + '-' + String(expiresAt.getDate()).padStart(2,'0') + 'T' + String(expiresAt.getHours()).padStart(2,'0') + ':' + String(expiresAt.getMinutes()).padStart(2,'0')
: '';
setForm({
title: c.title || '',
description: c.description || '',
sign_order_type: c.sign_order_type || 'counterpart_first',
expires_at: expiresStr,
creator_name: creator.name || '',
creator_email: creator.email || '',
creator_phone: creator.phone || '',
counterpart_name: counterpart.name || '',
counterpart_email: counterpart.email || '',
counterpart_phone: counterpart.phone || '',
});
// 제목 타입 판별
const preset = TITLE_PRESETS.find(p => p.value === c.title);
setTitleType(preset ? c.title : '__custom__');
if (c.original_file_name) setExistingFileName(c.original_file_name);
}
})
.catch(() => { alert('계약 정보를 불러오지 못했습니다.'); })
.finally(() => setEditLoading(false));
}
}, []);
const handleChange = (key, val) => setForm(f => ({...f, [key]: val}));
@@ -697,6 +741,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
const goNext = () => {
if (step === 1) {
if (!validateStep1()) return;
if (IS_EDIT) { handleSubmit(); return; }
if (hasTemplates) { setStep(2); } else { handleSubmit(); }
} else if (step === 2) {
applyTitleDefaults(form.title);
@@ -718,7 +763,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
}
};
// 계약 생성 제출
// 계약 생성/수정 제출
const handleSubmit = async (goToSend = false) => {
setSubmitting(true);
setErrors({});
@@ -726,8 +771,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); });
if (file) fd.append('file', file);
if (templateId) fd.append('template_id', templateId);
if (Object.keys(metadata).length > 0) {
if (!IS_EDIT && templateId) fd.append('template_id', templateId);
if (!IS_EDIT && Object.keys(metadata).length > 0) {
Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); });
}
@@ -742,21 +787,35 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
fd.append('signers[1][phone]', form.counterpart_phone || '');
fd.append('signers[1][role]', 'counterpart');
const res = await fetch('/esign/contracts/store', {
let url, method;
if (IS_EDIT) {
url = `/esign/contracts/${EDIT_CONTRACT_ID}`;
method = 'PUT';
// FormData는 PUT을 지원하지 않으므로 _method 사용
fd.append('_method', 'PUT');
} else {
url = '/esign/contracts/store';
method = 'POST';
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: fd,
});
const json = await res.json();
if (json.success) {
if (goToSend && json.auto_applied) {
location.href = `/esign/${json.data.id}/send`;
const contractId = IS_EDIT ? EDIT_CONTRACT_ID : json.data.id;
if (IS_EDIT) {
location.href = `/esign/${contractId}`;
} else if (goToSend && json.auto_applied) {
location.href = `/esign/${contractId}/send`;
} else {
location.href = `/esign/${json.data.id}/fields`;
location.href = `/esign/${contractId}/fields`;
}
} else {
setErrors(json.errors || { general: json.message });
if (json.errors) setStep(1); // 서버 유효성 에러 → step 1으로
if (json.errors) setStep(1);
}
} catch (e) {
setErrors({ general: '서버 오류가 발생했습니다.' });
@@ -804,6 +863,7 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" onChange={handleFileSelect}
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm file:mr-3 file:py-0.5 file:px-2.5 file:rounded file:border-0 file:bg-blue-50 file:text-blue-700 file:text-xs file:font-medium file:cursor-pointer" />
{file && <p className="text-xs text-gray-500 mt-1">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
{!file && IS_EDIT && existingFileName && <p className="text-xs text-gray-500 mt-1">현재 파일: {existingFileName} <span className="text-gray-400">( 파일을 선택하면 교체됩니다)</span></p>}
</div>
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px' }}>
@@ -862,10 +922,10 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
{/* 네비게이션 */}
<div className="flex justify-between">
<a href="/esign" className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>
<button type="button" onClick={goNext}
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors">
{hasTemplates ? '다음 \u2192' : '계약 생성 및 서명 위치 설정'}
<a href={IS_EDIT ? `/esign/${EDIT_CONTRACT_ID}` : '/esign'} className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>
<button type="button" onClick={goNext} disabled={submitting}
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors disabled:opacity-50">
{IS_EDIT ? (submitting ? '수정 중...' : '계약 수정') : hasTemplates ? '다음 \u2192' : '계약 생성 및 서명 위치 설정'}
</button>
</div>
</div>
@@ -1095,7 +1155,7 @@ className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-
&larr; 이전
</button>
<div className="flex gap-3">
{templateId && (
{!IS_EDIT && templateId && (
<button type="button" disabled={submitting}
onClick={() => handleSubmit(true)}
className="px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm font-medium disabled:opacity-50 transition-colors">
@@ -1105,7 +1165,7 @@ className="px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 tex
<button type="button" disabled={submitting}
onClick={() => handleSubmit(false)}
className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50 transition-colors">
{submitting ? '생성 중...' : templateId ? '계약 생성 및 필드 확인' : '계약 생성 및 서명 위치 설정'}
{submitting ? (IS_EDIT ? '수정 중...' : '생성 중...') : IS_EDIT ? '계약 수정' : templateId ? '계약 생성 및 필드 확인' : '계약 생성 및 서명 위치 설정'}
</button>
</div>
</div>
@@ -1117,9 +1177,11 @@ className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
<PartnerSearchModal open={partnerModalOpen} onClose={() => setPartnerModalOpen(false)} onSelect={handlePartnerSelect} />
<TenantSearchModal open={tenantModalOpen} onClose={() => setTenantModalOpen(false)} onSelect={handleTenantSelect} />
{/* 헤더 */}
{editLoading && <div className="p-6 text-center text-gray-400">계약 정보를 불러오는 ...</div>}
{!editLoading && <>
<div className="flex items-center gap-3 mb-2">
<a href="/esign" className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">&larr;</a>
<h1 className="text-xl font-bold text-gray-900"> 계약 생성</h1>
<a href={IS_EDIT ? `/esign/${EDIT_CONTRACT_ID}` : '/esign'} className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">&larr;</a>
<h1 className="text-xl font-bold text-gray-900">{IS_EDIT ? '계약 수정' : '새 계약 생성'}</h1>
{IS_ADMIN && (
<button type="button" onClick={fillRandomCounterpart} title="랜덤 상대방 정보 채우기"
className="w-8 h-8 flex items-center justify-center rounded-lg text-amber-500 hover:bg-amber-50 hover:text-amber-600 transition-colors">
@@ -1140,6 +1202,7 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-amber-500 ho
{step === 1 && renderStep1()}
{step === 2 && renderStep2()}
{step === 3 && renderStep3()}
</>}
</div>
);
};

View File

@@ -255,6 +255,9 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
<div className="space-y-3">
{c.status === 'draft' && (
<>
<a href={`/esign/${c.id}/edit`} className="block w-full text-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm font-medium" hx-boost="false">
계약 정보 수정
</a>
<a href={`/esign/${c.id}/fields`} className="block w-full text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" hx-boost="false">
서명 위치 설정
</a>

View File

@@ -1783,6 +1783,7 @@
Route::get('/templates', [EsignController::class, 'templates'])->name('templates');
Route::get('/templates/{templateId}/fields', [EsignController::class, 'templateFields'])->whereNumber('templateId')->name('template-fields');
Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail');
Route::get('/{id}/edit', [EsignController::class, 'edit'])->whereNumber('id')->name('edit');
Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields');
Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send');
@@ -1800,6 +1801,7 @@
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
Route::get('/list', [EsignApiController::class, 'index'])->name('list');
Route::post('/store', [EsignApiController::class, 'store'])->name('store');
Route::put('/{id}', [EsignApiController::class, 'update'])->whereNumber('id')->name('update');
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');