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:
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인도장 조회
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,9 @@ class SalesPartner extends Model
|
||||
'total_contracts',
|
||||
'total_commission',
|
||||
'notes',
|
||||
'company_name',
|
||||
'biz_no',
|
||||
'address',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업파트너 첨부 서류
|
||||
*/
|
||||
|
||||
@@ -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">×</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">←</a>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user