diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 3bf555a1..f15402c4 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -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]); + } + /** * 법인도장 조회 */ diff --git a/app/Models/Sales/SalesPartner.php b/app/Models/Sales/SalesPartner.php index 627be054..56f27e02 100644 --- a/app/Models/Sales/SalesPartner.php +++ b/app/Models/Sales/SalesPartner.php @@ -48,6 +48,9 @@ class SalesPartner extends Model 'total_contracts', 'total_commission', 'notes', + 'company_name', + 'biz_no', + 'address', ]; protected $casts = [ diff --git a/app/Models/User.php b/app/Models/User.php index 3780779a..e6eff6db 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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'); + } + /** * 영업파트너 첨부 서류 */ diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index df704ca9..61a1afff 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -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 ( +
+
+
e.stopPropagation()}> +
+

영업파트너 검색

+ +
+
+ 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" /> +
+
+ {loading &&

검색 중...

} + {!loading && results.length === 0 &&

검색 결과가 없습니다.

} + {!loading && results.map((p, i) => ( + + ))} +
+
+
+ ); +}; + // ─── 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 && (
-
직접 입력 항목
+
+
직접 입력 항목
+ +
{templateVars.map(v => ( + setPartnerModalOpen(false)} onSelect={handlePartnerSelect} /> {/* 헤더 */}
diff --git a/routes/web.php b/routes/web.php index 5bb8f3cf..0bcee5c7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');