feat:E-Sign 필드 템플릿 저장/불러오기 및 계약 간 복사 기능

- EsignFieldTemplate, EsignFieldTemplateItem 모델 추가
- EsignApiController에 템플릿 CRUD + 적용/복사 메서드 5개 추가
- web.php에 템플릿 라우트 5개 추가
- fields.blade.php에 템플릿 드롭다운 메뉴 + 모달 3개 추가
  (SaveTemplate, LoadTemplate, CopyFromContract)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 18:02:31 +09:00
parent 4f25e0e4a1
commit 79c23f3337
5 changed files with 711 additions and 27 deletions

View File

@@ -5,11 +5,14 @@
use App\Http\Controllers\Controller;
use App\Mail\EsignRequestMail;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignFieldTemplate;
use App\Models\ESign\EsignFieldTemplateItem;
use App\Models\ESign\EsignSigner;
use App\Models\ESign\EsignSignField;
use App\Models\ESign\EsignAuditLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
@@ -350,4 +353,230 @@ public function download(int $id)
'Content-Type' => 'application/pdf',
]);
}
// ─── 필드 템플릿 관련 메서드 ───
/**
* 템플릿 목록 조회
*/
public function indexTemplates(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = EsignFieldTemplate::forTenant($tenantId)
->where('is_active', true)
->with('items');
if ($signerCount = $request->input('signer_count')) {
$query->where('signer_count', $signerCount);
}
$templates = $query->orderBy('created_at', 'desc')->get();
return response()->json(['success' => true, 'data' => $templates]);
}
/**
* 템플릿 저장 (현재 필드를 템플릿으로)
*/
public function storeTemplate(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.signer_order' => 'required|integer|min:1',
'items.*.page_number' => 'required|integer|min:1',
'items.*.position_x' => 'required|numeric',
'items.*.position_y' => 'required|numeric',
'items.*.width' => 'required|numeric',
'items.*.height' => 'required|numeric',
'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox',
'items.*.field_label' => 'nullable|string|max:100',
'items.*.is_required' => 'nullable|boolean',
]);
$tenantId = session('selected_tenant_id', 1);
// items에서 최대 signer_order를 추출하여 signer_count 결정
$items = $request->input('items');
$signerCount = max(array_column($items, 'signer_order'));
$template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount) {
$template = EsignFieldTemplate::create([
'tenant_id' => $tenantId,
'name' => $request->input('name'),
'description' => $request->input('description'),
'signer_count' => $signerCount,
'is_active' => true,
'created_by' => auth()->id(),
]);
foreach ($items as $i => $item) {
EsignFieldTemplateItem::create([
'template_id' => $template->id,
'signer_order' => $item['signer_order'],
'page_number' => $item['page_number'],
'position_x' => $item['position_x'],
'position_y' => $item['position_y'],
'width' => $item['width'],
'height' => $item['height'],
'field_type' => $item['field_type'],
'field_label' => $item['field_label'] ?? null,
'is_required' => $item['is_required'] ?? true,
'sort_order' => $i,
]);
}
return $template;
});
return response()->json([
'success' => true,
'message' => '필드 템플릿이 저장되었습니다.',
'data' => $template->load('items'),
]);
}
/**
* 템플릿 삭제 (soft: is_active=false)
*/
public function destroyTemplate(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
$template->update(['is_active' => false]);
return response()->json(['success' => true, 'message' => '템플릿이 삭제되었습니다.']);
}
/**
* 템플릿을 계약에 적용
*/
public function applyTemplate(Request $request, int $id): JsonResponse
{
$request->validate([
'template_id' => 'required|integer',
]);
$tenantId = session('selected_tenant_id', 1);
$contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id);
$template = EsignFieldTemplate::forTenant($tenantId)
->where('is_active', true)
->with('items')
->findOrFail($request->input('template_id'));
// 서명자 수 확인
$contractSignerCount = $contract->signers->count();
if ($template->signer_count > $contractSignerCount) {
return response()->json([
'success' => false,
'message' => "템플릿에 필요한 서명자 수({$template->signer_count}명)가 계약의 서명자 수({$contractSignerCount}명)보다 많습니다.",
], 422);
}
// signer_order → signer_id 매핑
$signerMap = [];
foreach ($contract->signers as $signer) {
$signerMap[$signer->sign_order] = $signer->id;
}
DB::transaction(function () use ($contract, $template, $tenantId, $signerMap) {
// 기존 필드 삭제
EsignSignField::where('contract_id', $contract->id)->delete();
// 템플릿 아이템 → 필드 생성
foreach ($template->items as $item) {
$signerId = $signerMap[$item->signer_order] ?? null;
if (!$signerId) continue;
EsignSignField::create([
'tenant_id' => $tenantId,
'contract_id' => $contract->id,
'signer_id' => $signerId,
'page_number' => $item->page_number,
'position_x' => $item->position_x,
'position_y' => $item->position_y,
'width' => $item->width,
'height' => $item->height,
'field_type' => $item->field_type,
'field_label' => $item->field_label,
'is_required' => $item->is_required,
'sort_order' => $item->sort_order,
]);
}
});
$fields = EsignSignField::where('contract_id', $contract->id)->orderBy('sort_order')->get();
return response()->json([
'success' => true,
'message' => '템플릿이 적용되었습니다.',
'data' => $fields,
]);
}
/**
* 다른 계약에서 필드 복사
*/
public function copyFieldsFromContract(Request $request, int $id, int $sourceId): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$targetContract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id);
$sourceContract = EsignContract::forTenant($tenantId)->with(['signers', 'signFields'])->findOrFail($sourceId);
if ($sourceContract->signFields->isEmpty()) {
return response()->json([
'success' => false,
'message' => '소스 계약에 복사할 필드가 없습니다.',
], 422);
}
// 소스 계약 서명자의 sign_order → signer_id 매핑
$sourceSignerOrderMap = [];
foreach ($sourceContract->signers as $signer) {
$sourceSignerOrderMap[$signer->id] = $signer->sign_order;
}
// 대상 계약 sign_order → signer_id 매핑
$targetSignerMap = [];
foreach ($targetContract->signers as $signer) {
$targetSignerMap[$signer->sign_order] = $signer->id;
}
DB::transaction(function () use ($targetContract, $sourceContract, $tenantId, $sourceSignerOrderMap, $targetSignerMap) {
// 기존 필드 삭제
EsignSignField::where('contract_id', $targetContract->id)->delete();
foreach ($sourceContract->signFields as $field) {
// 소스 signer_id → sign_order → 대상 signer_id
$signOrder = $sourceSignerOrderMap[$field->signer_id] ?? null;
$targetSignerId = $signOrder ? ($targetSignerMap[$signOrder] ?? null) : null;
if (!$targetSignerId) continue;
EsignSignField::create([
'tenant_id' => $tenantId,
'contract_id' => $targetContract->id,
'signer_id' => $targetSignerId,
'page_number' => $field->page_number,
'position_x' => $field->position_x,
'position_y' => $field->position_y,
'width' => $field->width,
'height' => $field->height,
'field_type' => $field->field_type,
'field_label' => $field->field_label,
'is_required' => $field->is_required,
'sort_order' => $field->sort_order,
]);
}
});
$fields = EsignSignField::where('contract_id', $targetContract->id)->orderBy('sort_order')->get();
return response()->json([
'success' => true,
'message' => '필드가 복사되었습니다.',
'data' => $fields,
]);
}
}