feat:E-Sign 계약 생성 영업파트너 검색 모달 추가

- EsignApiController에 searchPartners() 검색 API 추가
- SalesPartner 모델 $fillable에 company_name, biz_no, address 추가
- User 모델에 salesPartner() HasOne 관계 추가
- create.blade.php에 PartnerSearchModal 컴포넌트 + 자동채우기 로직 추가
- web.php에 search-partners 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-14 10:48:31 +09:00
parent e0627e04ac
commit 92c78b353e
5 changed files with 187 additions and 1 deletions

View File

@@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Mail\EsignRequestMail;
use App\Models\ESign\EsignContract;
use App\Models\User;
use App\Services\ESign\DocxToPdfConverter;
use App\Models\ESign\EsignFieldTemplate;
use App\Models\ESign\EsignFieldTemplateItem;
@@ -22,6 +23,43 @@
class EsignApiController extends Controller
{
/**
* 영업파트너 검색
*/
public function searchPartners(Request $request): JsonResponse
{
$q = trim($request->input('q', ''));
$query = User::where('is_active', true)
->whereIn('role', ['sales', 'manager'])
->with('salesPartner');
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%")
->orWhere('phone', 'like', "%{$q}%");
});
}
$users = $query->orderBy('name')->limit(20)->get();
$data = $users->map(function ($user) {
$sp = $user->salesPartner;
return [
'id' => $user->id,
'name' => $user->name,
'phone' => $user->phone,
'email' => $user->email,
'company_name' => $sp?->company_name,
'biz_no' => $sp?->biz_no,
'address' => $sp?->address,
];
});
return response()->json(['success' => true, 'data' => $data]);
}
/**
* 법인도장 조회
*/

View File

@@ -48,6 +48,9 @@ class SalesPartner extends Model
'total_contracts',
'total_commission',
'notes',
'company_name',
'biz_no',
'address',
];
protected $casts = [

View File

@@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -95,6 +96,14 @@ public function approver(): \Illuminate\Database\Eloquent\Relations\BelongsTo
return $this->belongsTo(User::class, 'approved_by');
}
/**
* 영업파트너 정보
*/
public function salesPartner(): HasOne
{
return $this->hasOne(\App\Models\Sales\SalesPartner::class, 'user_id');
}
/**
* 영업파트너 첨부 서류
*/

View File

@@ -263,6 +263,86 @@ className={`flex items-center gap-1.5 ${i + 1 < currentStep ? 'cursor-pointer' :
);
};
// ─── PartnerSearchModal ───
const PartnerSearchModal = ({ open, onClose, onSelect }) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const inputRef = useRef(null);
const debounceRef = useRef(null);
const doSearch = useCallback((q) => {
setLoading(true);
fetch(`/esign/contracts/search-partners?q=${encodeURIComponent(q)}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
})
.then(r => r.json())
.then(json => { if (json.success) { setResults(json.data); setActiveIdx(-1); } })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!open) return;
setQuery(''); setResults([]); setActiveIdx(-1);
doSearch('');
setTimeout(() => inputRef.current?.focus(), 100);
}, [open, doSearch]);
const handleInput = (val) => {
setQuery(val);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 250);
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(results.length - 1, i + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(0, i - 1)); }
else if (e.key === 'Enter' && activeIdx >= 0 && results[activeIdx]) { e.preventDefault(); onSelect(results[activeIdx]); onClose(); }
else if (e.key === 'Escape') { onClose(); }
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b">
<h3 className="text-sm font-semibold text-gray-900">영업파트너 검색</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg">&times;</button>
</div>
<div className="px-5 py-3">
<input ref={inputRef} type="text" value={query}
onChange={e => handleInput(e.target.value)} onKeyDown={handleKeyDown}
placeholder="이름, 이메일 또는 전화번호로 검색..."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
</div>
<div className="px-5 pb-4 max-h-[320px] overflow-y-auto">
{loading && <p className="text-xs text-gray-400 py-3 text-center">검색 ...</p>}
{!loading && results.length === 0 && <p className="text-xs text-gray-400 py-3 text-center">검색 결과가 없습니다.</p>}
{!loading && results.map((p, i) => (
<button key={p.id} type="button"
onClick={() => { onSelect(p); onClose(); }}
onMouseEnter={() => setActiveIdx(i)}
className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i === activeIdx ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-800">{p.name}</span>
{p.company_name && <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{p.company_name}</span>}
</div>
<div className="flex items-center gap-3 mt-0.5">
{p.phone && <span className="text-xs text-gray-400">{p.phone}</span>}
{p.email && <span className="text-xs text-gray-400">{p.email}</span>}
</div>
</button>
))}
</div>
</div>
</div>
);
};
// ─── App (Wizard) ───
const App = () => {
const [step, setStep] = useState(1);
@@ -282,6 +362,7 @@ className={`flex items-center gap-1.5 ${i + 1 < currentStep ? 'cursor-pointer' :
const [templateSearch, setTemplateSearch] = useState('');
const [templateItems, setTemplateItems] = useState([]);
const [metadata, setMetadata] = useState({});
const [partnerModalOpen, setPartnerModalOpen] = useState(false);
const fileRef = useRef(null);
const hasTemplates = templates.length > 0;
@@ -346,6 +427,50 @@ className={`flex items-center gap-1.5 ${i + 1 < currentStep ? 'cursor-pointer' :
}));
};
// 파트너 선택 시 자동 채우기
const handlePartnerSelect = (partner) => {
// key 기반 매핑 (변수 key → 파트너 필드)
const keyMap = {
partner_name: partner.name,
phone: partner.phone,
address: partner.address,
biz_no: partner.biz_no,
company_name: partner.company_name,
};
// label 기반 fallback 매핑
const labelMap = {
'파트너명': partner.name,
'전화번호': partner.phone,
'주소': partner.address,
'사업자등록번호': partner.biz_no,
'상호': partner.company_name,
};
setMetadata(prev => {
const updated = { ...prev };
templateVars.forEach(v => {
// key 매칭 우선
if (keyMap[v.key] !== undefined && keyMap[v.key]) {
updated[v.key] = keyMap[v.key];
return;
}
// label fallback
if (labelMap[v.label] !== undefined && labelMap[v.label]) {
updated[v.key] = labelMap[v.label];
}
});
return updated;
});
// 상대방 서명자 정보도 자동 채우기
setForm(f => ({
...f,
counterpart_name: partner.name || f.counterpart_name,
counterpart_email: partner.email || f.counterpart_email,
counterpart_phone: partner.phone || f.counterpart_phone,
}));
};
// Step 1 유효성 검사
const validateStep1 = () => {
const newErrors = {};
@@ -668,7 +793,16 @@ className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
{/* 커스텀 변수 (직접 입력) */}
{templateVars.length > 0 && (
<div>
<div className="text-[11px] font-medium text-gray-500 mb-1.5">직접 입력 항목</div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] font-medium text-gray-500">직접 입력 항목</div>
<button type="button" onClick={() => setPartnerModalOpen(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-amber-300 text-amber-700 rounded-md text-xs font-medium hover:bg-amber-50 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
영업파트너 불러오기
</button>
</div>
<div className="space-y-3">
{templateVars.map(v => (
<Input key={v.key} label={v.label} name={`meta_${v.key}`}
@@ -715,6 +849,7 @@ className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
return (
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
<PartnerSearchModal open={partnerModalOpen} onClose={() => setPartnerModalOpen(false)} onSelect={handlePartnerSelect} />
{/* 헤더 */}
<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>

View File

@@ -1410,6 +1410,7 @@
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('/search-partners', [EsignApiController::class, 'searchPartners'])->name('search-partners');
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
Route::get('/list', [EsignApiController::class, 'index'])->name('list');