feat:법인도장 대시보드 등록 + 새 계약 자동 적용
- 대시보드 설정 탭 추가 (법인도장 등록/미리보기/삭제 UI) - tenant_settings 테이블에 esign/company_stamp 키로 저장 - 새 계약 생성 시 등록된 도장 자동 적용 (creator signer) - 계약 생성 페이지에서 개별 도장 업로드 UI 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
use App\Models\ESign\EsignSigner;
|
||||
use App\Models\ESign\EsignSignField;
|
||||
use App\Models\ESign\EsignAuditLog;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -20,6 +21,101 @@
|
||||
|
||||
class EsignApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* 법인도장 조회
|
||||
*/
|
||||
public function getStamp(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$setting = TenantSetting::where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'esign')
|
||||
->where('setting_key', 'company_stamp')
|
||||
->first();
|
||||
|
||||
if (!$setting || empty($setting->setting_value['image_path'])) {
|
||||
return response()->json(['success' => true, 'data' => null]);
|
||||
}
|
||||
|
||||
$path = $setting->setting_value['image_path'];
|
||||
$exists = Storage::disk('local')->exists($path);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $exists ? [
|
||||
'image_path' => $path,
|
||||
'image_url' => '/storage/' . $path,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인도장 업로드
|
||||
*/
|
||||
public function uploadStamp(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'stamp_image' => 'required|image|max:5120',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$file = $request->file('stamp_image');
|
||||
|
||||
$path = "esign/{$tenantId}/stamps/company_stamp.png";
|
||||
|
||||
// 기존 파일이 있으면 삭제
|
||||
if (Storage::disk('local')->exists($path)) {
|
||||
Storage::disk('local')->delete($path);
|
||||
}
|
||||
|
||||
Storage::disk('local')->put($path, file_get_contents($file->getRealPath()));
|
||||
|
||||
TenantSetting::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'setting_group' => 'esign',
|
||||
'setting_key' => 'company_stamp',
|
||||
],
|
||||
[
|
||||
'setting_value' => ['image_path' => $path],
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '법인도장이 등록되었습니다.',
|
||||
'data' => [
|
||||
'image_path' => $path,
|
||||
'image_url' => '/storage/' . $path,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인도장 삭제
|
||||
*/
|
||||
public function deleteStamp(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$setting = TenantSetting::where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'esign')
|
||||
->where('setting_key', 'company_stamp')
|
||||
->first();
|
||||
|
||||
if ($setting) {
|
||||
$path = $setting->setting_value['image_path'] ?? null;
|
||||
if ($path && Storage::disk('local')->exists($path)) {
|
||||
Storage::disk('local')->delete($path);
|
||||
}
|
||||
$setting->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '법인도장이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 통계
|
||||
*/
|
||||
@@ -99,7 +195,6 @@ public function store(Request $request): JsonResponse
|
||||
'metadata' => 'nullable|array',
|
||||
'metadata.*' => 'nullable|string|max:500',
|
||||
'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480',
|
||||
'creator_stamp_image' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
@@ -179,18 +274,22 @@ public function store(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
// 법인도장 이미지 처리
|
||||
if ($request->input('creator_stamp_image')) {
|
||||
// 법인도장 자동 적용: tenant_settings에 등록된 도장이 있으면 creator signer에 설정
|
||||
$stampSetting = TenantSetting::where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'esign')
|
||||
->where('setting_key', 'company_stamp')
|
||||
->first();
|
||||
|
||||
if ($stampSetting && !empty($stampSetting->setting_value['image_path'])) {
|
||||
$creatorSigner = EsignSigner::withoutGlobalScopes()
|
||||
->where('contract_id', $contract->id)
|
||||
->where('role', 'creator')
|
||||
->first();
|
||||
|
||||
if ($creatorSigner) {
|
||||
$imageData = base64_decode($request->input('creator_stamp_image'));
|
||||
$imagePath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
|
||||
Storage::disk('local')->put($imagePath, $imageData);
|
||||
$creatorSigner->update(['signature_image_path' => $imagePath]);
|
||||
$creatorSigner->update([
|
||||
'signature_image_path' => $stampSetting->setting_value['image_path'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,7 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
counterpart_name: '', counterpart_email: '', counterpart_phone: '',
|
||||
});
|
||||
const [file, setFile] = useState(null);
|
||||
const [stampImage, setStampImage] = useState(null);
|
||||
const [stampPreview, setStampPreview] = useState(null);
|
||||
const [registeredStamp, setRegisteredStamp] = useState(null); // { image_path, image_url }
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [templates, setTemplates] = useState([]);
|
||||
@@ -66,27 +65,12 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
const [templateSearch, setTemplateSearch] = useState('');
|
||||
const [metadata, setMetadata] = useState({});
|
||||
const fileRef = useRef(null);
|
||||
const stampFileRef = useRef(null);
|
||||
|
||||
const handleStampSelect = (e) => {
|
||||
const selected = e.target.files[0];
|
||||
if (!selected) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
setStampImage(base64);
|
||||
setStampPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(selected);
|
||||
};
|
||||
|
||||
const removeStamp = () => {
|
||||
setStampImage(null);
|
||||
setStampPreview(null);
|
||||
if (stampFileRef.current) stampFileRef.current.value = '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/esign/contracts/stamp', { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.json())
|
||||
.then(json => { if (json.success) setRegisteredStamp(json.data); })
|
||||
.catch(() => {});
|
||||
fetch('/esign/contracts/templates', { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.json())
|
||||
.then(json => { if (json.success) setTemplates(json.data); })
|
||||
@@ -153,8 +137,6 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); });
|
||||
}
|
||||
|
||||
if (stampImage) fd.append('creator_stamp_image', stampImage);
|
||||
|
||||
try {
|
||||
fd.append('_token', csrfToken);
|
||||
fd.append('signers[0][name]', form.creator_name);
|
||||
@@ -251,34 +233,28 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
<SignerRow prefix="creator" title="작성자" subtitle="나" color="#3B82F6"
|
||||
form={form} errors={errors} onChange={handleChange} />
|
||||
|
||||
{/* 법인도장 등록 */}
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
{/* 법인도장 (대시보드에서 관리) */}
|
||||
<div className={`rounded-lg p-3 border ${registeredStamp ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={registeredStamp ? '#3B82F6' : '#9CA3AF'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
<span className="text-xs font-semibold text-blue-800">법인도장</span>
|
||||
<span className="text-[11px] text-blue-500">발송 시 작성자 서명을 자동 처리합니다</span>
|
||||
<span className={`text-xs font-semibold ${registeredStamp ? 'text-blue-800' : 'text-gray-600'}`}>법인도장</span>
|
||||
<span className="text-[11px] text-gray-400">발송 시 작성자 서명을 자동 처리합니다</span>
|
||||
</div>
|
||||
{stampPreview ? (
|
||||
{registeredStamp ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={stampPreview} alt="법인도장 미리보기" className="h-16 w-16 object-contain border border-blue-300 rounded bg-white p-1" />
|
||||
<img src={registeredStamp.image_url + '?t=' + Date.now()} alt="법인도장" className="h-16 w-16 object-contain border border-blue-300 rounded bg-white p-1" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-blue-700">법인도장이 등록되었습니다.</p>
|
||||
<p className="text-xs text-blue-700">등록된 법인도장이 자동 적용됩니다.</p>
|
||||
<a href="/esign#settings" className="text-[11px] text-blue-500 hover:underline">대시보드에서 변경</a>
|
||||
</div>
|
||||
<button type="button" onClick={removeStamp}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full text-red-400 hover:bg-red-50 hover:text-red-600 transition-colors" title="삭제">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input ref={stampFileRef} type="file" accept="image/*" onChange={handleStampSelect} className="hidden" />
|
||||
<button type="button" onClick={() => stampFileRef.current?.click()}
|
||||
className="px-3 py-1.5 bg-white border border-blue-300 rounded-md text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
법인도장 이미지 등록
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 mb-1">등록된 법인도장이 없습니다.</p>
|
||||
<a href="/esign#settings" className="text-xs text-blue-600 hover:underline">대시보드에서 법인도장을 먼저 등록해 주세요</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -365,9 +365,119 @@ className={`px-3 py-1 text-sm rounded ${p === pagination.current_page ? 'bg-blue
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 설정 탭 ───
|
||||
const SettingsTab = () => {
|
||||
const [stamp, setStamp] = useState(null); // { image_path, image_url }
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileRef = React.useRef(null);
|
||||
|
||||
const fetchStamp = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/esign/contracts/stamp', { headers: getHeaders() });
|
||||
const json = await res.json();
|
||||
if (json.success) setStamp(json.data);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStamp(); }, []);
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('stamp_image', file);
|
||||
const res = await fetch('/esign/contracts/stamp', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setStamp(json.data);
|
||||
} else {
|
||||
alert(json.message || '업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) { alert('업로드 중 오류가 발생했습니다.'); }
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('법인도장을 삭제하시겠습니까?')) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch('/esign/contracts/stamp', {
|
||||
method: 'DELETE',
|
||||
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) setStamp(null);
|
||||
else alert(json.message || '삭제에 실패했습니다.');
|
||||
} catch (e) { alert('삭제 중 오류가 발생했습니다.'); }
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-8 text-gray-400">로딩 중...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<div className="bg-white rounded-lg border p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-1">법인도장 관리</h2>
|
||||
<p className="text-xs text-gray-400 mb-4">등록된 법인도장은 새 계약 생성 시 자동으로 적용됩니다.</p>
|
||||
|
||||
{stamp ? (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="border rounded-lg p-3 bg-gray-50">
|
||||
<img src={stamp.image_url + '?t=' + Date.now()} alt="법인도장" className="h-24 w-24 object-contain" />
|
||||
</div>
|
||||
<div className="flex-1 pt-2">
|
||||
<p className="text-sm text-green-700 font-medium mb-1">법인도장이 등록되어 있습니다.</p>
|
||||
<p className="text-xs text-gray-400 mb-3">새 계약 생성 시 작성자 서명에 자동 적용됩니다.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fileRef.current?.click()} disabled={uploading}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50">
|
||||
{uploading ? '교체 중...' : '이미지 교체'}
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={deleting}
|
||||
className="px-3 py-1.5 border border-red-300 rounded-md text-xs font-medium text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50">
|
||||
{deleting ? '삭제 중...' : '삭제'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-gray-300 mb-2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500 mb-3">등록된 법인도장이 없습니다.</p>
|
||||
<button onClick={() => fileRef.current?.click()} disabled={uploading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50">
|
||||
{uploading ? '업로드 중...' : '법인도장 이미지 등록'}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
<p className="text-xs text-gray-400 mt-2">PNG, JPG 형식 (최대 5MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── App ───
|
||||
const App = () => {
|
||||
const [tab, setTab] = useState('contracts'); // 'contracts' | 'trash'
|
||||
const [tab, setTab] = useState(() => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
return ['contracts', 'trash', 'settings'].includes(hash) ? hash : 'contracts';
|
||||
}); // 'contracts' | 'trash' | 'settings'
|
||||
const [stats, setStats] = useState({});
|
||||
const [trashCount, setTrashCount] = useState(0);
|
||||
|
||||
@@ -435,11 +545,19 @@ className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${tab === 'trash' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'}`}>{trashCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => setTab('settings')}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px inline-flex items-center gap-1.5 ${tab === 'settings' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>
|
||||
</svg>
|
||||
설정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
{tab === 'contracts' && <ContractList onRefreshStats={refreshAll} />}
|
||||
{tab === 'trash' && <TrashList onRefreshStats={refreshAll} />}
|
||||
{tab === 'settings' && <SettingsTab />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1407,6 +1407,10 @@
|
||||
|
||||
// 내부 API 라우트 (Finance 패턴과 동일 - 직접 DB 접근)
|
||||
Route::prefix('contracts')->name('contracts.')->group(function () {
|
||||
Route::get('/stamp', [EsignApiController::class, 'getStamp'])->name('stamp.get');
|
||||
Route::post('/stamp', [EsignApiController::class, 'uploadStamp'])->name('stamp.upload');
|
||||
Route::delete('/stamp', [EsignApiController::class, 'deleteStamp'])->name('stamp.delete');
|
||||
|
||||
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
|
||||
Route::get('/list', [EsignApiController::class, 'index'])->name('list');
|
||||
Route::post('/store', [EsignApiController::class, 'store'])->name('store');
|
||||
|
||||
Reference in New Issue
Block a user