PageSourceSearch

https://tfonev07pwainstallable.vercel.app/post162/tfone-bid-multi-…?v=bid-multi-notice-p0-1

js tfonev07pwainstallable.vercel.app collected 2026-10-03 07:52:21 UTC 9,808 bytes, 68 lines download raw bytes

1/* TF ONE bid multi-notice selector P0 · 2026-09-03
2 * - detects ambiguous bid-list AI reviews
3 * - lets staff specify row / notice title / notice no without re-uploading the list
4 * - re-analyzes the same resource against exactly one target notice
5 * - preserves the normal review/registration form and applies the targeted analysis job
6 */
7(()=>{
8'use strict';
9const KEY='__TFONE_BID_MULTI_NOTICE_SELECTOR_P0__';
10if(globalThis[KEY])return;
11const state={active:false,lastAnalyze:null,targeted:null,observer:null,enhanced:0,lastError:''};
12const nativeFetch=globalThis.fetch.bind(globalThis);
13const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
14function parseBody(init){try{return typeof init?.body==='string'?JSON.parse(init.body):null}catch{return null}}
15function ambiguityText(r){return [r?.summary,...(r?.risks||[]),...(r?.next_actions||[])].filter(Boolean).join(' ')}
16function ambiguous(r){const t=ambiguityText(r);return /단일\s*(?:입찰)?공고.*(?:지정|특정)|행번호|index|서로\s*다른\s*입찰공고|다수\s*(?:의\s*)?입찰공고|여러\s*(?:개의\s*)?입찰공고|공고.*목록/i.test(t)}
17async function authToken(){
18 try{for(let i=0;i<localStorage.length;i++){const k=localStorage.key(i)||'';if(!/^sb-.*-auth-token$/.test(k))continue;const v=JSON.parse(localStorage.getItem(k)||'{}');const t=v?.access_token||v?.currentSession?.access_token||v?.session?.access_token;if(t)return t}}catch{}
19 try{const m=await import('https://esm.sh/@supabase/supabase-js@2');const createClient=m.createClient||m.default?.createClient;if(typeof createClient==='function'){const c=createClient('https://tfswpzkwdmghybmxstpj.supabase.co','sb_publishable_IdMAgUkKrRkqG0RM6HGhxQ_LhAXjbdN',{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:false}});let {data}=await c.auth.getSession();let s=data?.session||null;const exp=Number(s?.expires_at||0)*1000;if(s&&exp&&exp-Date.now()<60000){const r=await c.auth.refreshSession();s=r.data?.session||s}if(s?.access_token)return s.access_token}}catch{}
20 return '';
21}
22function localInput(v){if(!v)return'';const s=String(v);if(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s))return s.slice(0,16);try{const d=new Date(v);if(!Number.isFinite(d.getTime()))return'';const z=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())}T${z(d.getHours())}:${z(d.getMinutes())}`}catch{return''}}
23function setValue(form,id,v){const el=form.querySelector(id);if(!el)return;if(el.tagName==='SELECT'){const x=String(v??'');if([...el.options].some(o=>o.value===x||o.text===x))el.value=x;return}el.value=v??''}
24function populate(form,out){const r=out?.result||{};setValue(form,'#aiBidOrg',r.organization_name);setValue(form,'#aiBidTitle',r.title);setValue(form,'#aiBidNotice',r.notice_no);setValue(form,'#aiBidBudget',r.budget);setValue(form,'#aiBidDeadline',localInput(r.deadline));setValue(form,'#aiBidPt',localInput(r.presentation_at));setValue(form,'#aiBidPlace',r.presentation_place);setValue(form,'#aiBidGrade',r.fit_grade||'확인필요');setValue(form,'#aiBidContract',r.contract_period);setValue(form,'#aiBidQual',r.qualification);setValue(form,'#aiBidDocs',(r.required_documents||[]).join('\n'));setValue(form,'#aiBidRisks',(r.risks||[]).join('\n'));setValue(form,'#aiBidNext',(r.next_actions||[]).join('\n'));
25 const card=form.closest('.modal-card');const head=card?.querySelector('.ai-review-head');if(head){const b=head.querySelector('b');if(b)b.textContent=r.summary||'선택 공고 분석 완료';const small=head.querySelector('small');if(small)small.textContent=`AI 신뢰도 ${Math.round(Number(r.confidence||0)*100)}%`;const pill=head.querySelector('.pill');if(pill){pill.textContent=r.fit_grade||'확인필요';pill.classList.toggle('important',r.fit_grade==='A')}}
26 form.dataset.tfoneTargetAnalysisId=out.analysis_id||'';state.targeted={analysisId:out.analysis_id,result:r,resourceId:state.lastAnalyze?.resourceId||null,targetHint:out.target_hint||''};
27}
28function style(){if($('#tfone-bid-target-style'))return;const s=document.createElement('style');s.id='tfone-bid-target-style';s.textContent=`
29 .tfone-bid-target{grid-column:1/-1;border:1px solid #e5c77d;border-left:5px solid #d59a22;background:#fffaf0;border-radius:13px;padding:12px 13px;margin:0 0 12px;color:#3b4650}.tfone-bid-target b{display:block;color:#173f5a;font-size:13px;margin-bottom:5px}.tfone-bid-target p{font-size:10px;line-height:1.55;color:#687b89;margin:4px 0 9px}.tfone-bid-target-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.tfone-bid-target input{min-width:0;width:100%;border:1px solid #cbd8e2;border-radius:10px;padding:10px 11px;font-size:12px;background:#fff}.tfone-bid-target button{border:0;border-radius:10px;background:#0b2d4f;color:#fff;font-weight:900;padding:10px 13px;white-space:nowrap}.tfone-bid-target button:disabled{opacity:.55}.tfone-bid-target .target-state{display:block;font-size:10px;font-weight:800;margin-top:7px;color:#8b5b21}.tfone-bid-target.done{border-color:#93cdb0;background:#f2fbf6}.tfone-bid-target.done .target-state{color:#18724c}.tfone-bid-target-help{grid-column:1/-1;font-size:9px;color:#728492;margin-top:6px}
30 @media(max-width:640px){.tfone-bid-target-row{grid-template-columns:1fr}.tfone-bid-target button{width:100%}}
31 `;document.head.appendChild(s)}
32async function targetedAnalyze(form,hint,btn,status){
33 const resourceId=state.lastAnalyze?.resourceId;if(!resourceId)throw new Error('원본 자료 식별자를 찾지 못했습니다. 자료함에서 AI 분석을 다시 눌러 주세요.');
34 btn.disabled=true;status.textContent='선택 공고만 다시 분석 중...';
35 try{
36  const token=await authToken();if(!token)throw new Error('로그인 세션을 확인하지 못했습니다. 다시 로그인 후 시도해 주세요.');
37  const res=await nativeFetch('/api/bid-target-analyze',{method:'POST',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({resource_id:resourceId,target_hint:hint})});
38  const text=await res.text();let out={};try{out=text?JSON.parse(text):{}}catch{}
39  if(!res.ok)throw new Error(out.error||`선택 공고 분석 오류 ${res.status}`);
40  populate(form,out);const panel=form.querySelector('.tfone-bid-target');if(panel)panel.classList.add('done');status.textContent='선택 공고 분석 완료 · 아래 내용 확인 후 등록';
41  const input=form.querySelector('.tfone-bid-target-input');if(input)input.disabled=true;btn.textContent='분석 완료';
42 }catch(e){state.lastError=String(e?.message||e);status.textContent=`분석 실패 · ${state.lastError}`;btn.disabled=false;throw e}
43}
44function enhance(form){if(!form||form.dataset.tfoneBidTargetEnhanced)return;const r=state.lastAnalyze?.out?.result||{};
44if(!ambiguous(r))return;style();form.dataset.tfoneBidTargetEnhanced='1';state.enhanced++;
45 const first=form.querySelector('.form-grid');if(!first)return;const panel=document.createElement('div');panel.className='tfone-bid-target';panel.innerHTML=`<b>목록 파일 · 분석할 공고 1건을 지정해 주세요</b><p>파일을 다시 올릴 필요 없습니다. <strong>행번호, 공고명, 공고번호 중 하나</strong>를 입력하면 같은 원본에서 그 공고만 다시 읽습니다.</p><div class="tfone-bid-target-row"><input class="tfone-bid-target-input" placeholder="예: 5행 / ○○복지관 식자재 납품 / R26BK..."><button type="button" class="tfone-bid-target-run">선택 공고 분석</button></div><span class="target-state">아직 대상 공고를 지정하지 않았습니다.</span><div class="tfone-bid-target-help">공고별 세부 원문·제안요청서가 별도 파일이면 이 목록을 먼저 특정한 뒤, 자료함에서 그 원문을 ‘입찰공고’로 추가 등록하면 됩니다.</div>`;first.prepend(panel);
46 const input=panel.querySelector('.tfone-bid-target-input'),btn=panel.querySelector('.tfone-bid-target-run'),status=panel.querySelector('.target-state');btn.onclick=async()=>{const hint=input.value.trim();if(!hint){status.textContent='행번호·공고명·공고번호 중 하나를 입력해 주세요.';input.focus();return}try{await targetedAnalyze(form,hint,btn,status)}catch{}};
47}
48function scan(){const form=$('#aiBidReview');if(form)enhance(form)}
49function beforeFetch(input,init){
50 const url=typeof input==='string'?input:(input?.url||'');
51 if(/\/api\/ai-analysis-apply(?:\?|$)/.test(url)){
52  const body=parseBody(init),form=$('#aiBidReview');const targetId=form?.dataset?.tfoneTargetAnalysisId;
53  if(body&&targetId&&state.targeted?.result){body.analysis_id=targetId;body.result={...state.targeted.result,...body.result,summary:state.targeted.result.summary,confidence:state.targeted.result.confidence};init={...(init||{}),body:JSON.stringify(body)}}
54 }
55 return {input,init};
56}
57async function afterFetch(input,init,response){
58 const url=typeof input==='string'?input:(input?.url||'');
59 if(/\/api\/ai-resource-analyze(?:\?|$)/.test(url)){
60  const req=parseBody(init);if(req?.analysis_type==='bid')try{const out=await response.clone().json();state.lastAnalyze={resourceId:req.resource_id,out,at:Date.now()};setTimeout(scan,0);setTimeout(scan,120)}catch{}
61 }
62 return response;
63}
64function start(){if(state.active)return;state.active=true;style();scan();for(const ms of [80,180,360,700])setTimeout(scan,ms);document.addEventListener('tfone:route',scan);addEventListener('pageshow',scan,{passive:true})}
65function stop(){document.removeEventListener('tfone:route',scan);removeEventListener('pageshow',scan);state.observer=null;state.active=false}
66globalThis[KEY]={start,stop,scan,beforeFetch,afterFetch,state:()=>({...state,observer:!!state.observer})};
67if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start();
68})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.