refactor: 작업실 핵심 스크립트를 기능별 파일로 분리 (core/parts/tasks/files/chat/init)
- workshop-app.js(83KB) → workshop-core.js(30KB: 인증·이스케이프·상태/저장/충돌/폴링·탭·지연 로더·모달/이력), workshop-parts.js(17KB), workshop-tasks.js(6KB: 작업+메모·개요), workshop-files.js(13KB: 파일·슬라이서), workshop-chat.js(18KB: 채팅·사진 첨부·리사이즈·모델 pill), workshop-init.js(1KB) - 원래 한 파일의 섹션 순서 그대로 로드(core→parts→tasks→files→chat→init)해 로딩 순서 문제를 만들지 않음. escHtml/safeUrl은 여러 모듈이 쓰므로 core로 이동 - 무손실 검증: 분리 전후 코드 줄 비교(사라진 코드 줄 0, 함수 선언 118개 동일), 실브라우저 45개 항목 + 실서버 읽기 전용 확인 - vm 저장 로직 테스트는 workshop-core.js를 읽도록 변경 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -142,9 +142,9 @@ describe('workshop_project 도구', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── 프런트엔드 저장 로직(workshop-app.js 상태/저장 구간을 vm으로 실행) ──────────────
|
||||
describe('workshop-app.js 저장 로직', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '../web-ui/js/app/workshop-app.js'), 'utf-8').split('\n'); // 2026-09-25 HTML에서 분리된 스크립트
|
||||
// ── 프런트엔드 저장 로직(workshop-core.js 상태/저장 구간을 vm으로 실행) ──────────────
|
||||
describe('workshop-core.js 저장 로직', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '../web-ui/js/app/workshop-core.js'), 'utf-8').split('\n'); // 2026-09-25 핵심 스크립트(기능별 분리 후)
|
||||
const start = html.findIndex(l => l.startsWith('function escAttr'));
|
||||
const from = html.findIndex(l => l.startsWith('let workshopData='));
|
||||
const to = html.findIndex(l => l.startsWith('// ── Tabs'));
|
||||
|
||||
@@ -176,6 +176,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../js/app/workshop-app.js"></script>
|
||||
<script src="../js/app/workshop-core.js"></script>
|
||||
<script src="../js/app/workshop-parts.js"></script>
|
||||
<script src="../js/app/workshop-tasks.js"></script>
|
||||
<script src="../js/app/workshop-files.js"></script>
|
||||
<script src="../js/app/workshop-chat.js"></script>
|
||||
<script src="../js/app/workshop-init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
// 작업실 앱 — 채팅 패널(2026-09-25 workshop-app.js를 기능별로 분리, 내용 변경 없음).
|
||||
// 채팅 송수신·렌더, 사진 첨부, 패널 리사이즈, 모델 선택.
|
||||
// classic script — 전역 함수/변수를 공유한다. HTML에서 core → parts → tasks → files → chat → init 순서로 로드(원래 한 파일의 순서 그대로).
|
||||
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
|
||||
// 세션ID를 프로젝트별로 분리한다("ws_" 접두어는 유지 — tool-scope.ts가 이 세션에서는
|
||||
// 키워드 없이도 workshop_project 도구를 항상 열어주고, 메인 사이드바 목록에서도 걸러진다).
|
||||
// projectId 기반 결정적 파생이므로 앱세션 저장소(app-sessions.json) 없이도 새로고침/
|
||||
// 재접속 시 같은 프로젝트의 이전 대화를 그대로 이어받는다. 옛 앱 전용 세션(ws_main/랜덤id)의
|
||||
// 대화는 앱 전체 공용이던 터라 프로젝트 귀속이 불가 — 어느 프로젝트에도 더 안 보인다.
|
||||
function getSessionId(){
|
||||
const proj=currentProject();
|
||||
return 'ws_'+(proj?proj.id:'noproject');
|
||||
}
|
||||
|
||||
let _chatLoadSeq=0;
|
||||
async function loadHistory(){
|
||||
const sid=getSessionId();
|
||||
const seq=++_chatLoadSeq;
|
||||
try{
|
||||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
||||
if(!r.ok)return;
|
||||
const d=await r.json();
|
||||
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
|
||||
if(!msgs.length||seq!==_chatLoadSeq)return;
|
||||
for(const m of msgs.slice(-30)){
|
||||
if(m.role==='user')addMsg('user',escHtml(m.content||''));
|
||||
else addMsg('assistant',renderMd(m.content||''));
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
|
||||
function addMsg(role,html){
|
||||
const msgs=document.getElementById('chat-msgs');
|
||||
const div=document.createElement('div');div.className='rb-msg '+role;
|
||||
const b=document.createElement('div');b.className='rb-bubble';b.innerHTML=html;
|
||||
if(typeof renderKatexIn==='function')renderKatexIn(b);
|
||||
div.appendChild(b);
|
||||
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
|
||||
return b;
|
||||
}
|
||||
|
||||
function renderMd(text){
|
||||
// 코드펜스를 먼저 자리표시자로 빼두고 모든 인라인/단락 처리가 끝난 뒤 복원한다 —
|
||||
// 그래야 예전처럼 \n\n→<p> 치환이 <pre> 안까지 파고들어 코드 블록의 빈 줄을
|
||||
// </p><p> 로 끊어먹는 사고가 안 난다. 인라인 `코드`/굵게 처리도 pre 안을 건드리지 못한다.
|
||||
const blocks=[];
|
||||
let s=String(text||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{
|
||||
blocks.push('<pre><code>'+code.replace(/^\n/,'')+'</code></pre>');
|
||||
return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n';
|
||||
});
|
||||
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
|
||||
// 인라인 수식 $...$ — KaTeX가 실려 있으면 삽입 직후 renderKatexIn이 진짜 수식으로
|
||||
// 그려주므로 그냥 둔다(09-24: 다른 앱들은 전부 KaTeX 실려 있었는데 작업실만 빠져 있었다).
|
||||
// KaTeX가 없는 환경의 폴백으로만 이 유니코드 치환을 돌린다. 자주 나오는 LaTeX 기호를
|
||||
// 유니코드로 바꾸고 $를 벗긴다. 내용에 백슬래시(LaTeX 명령)가 없으면 건드리지 않는다 —
|
||||
// 가격 "$19 ... $29" 짝을 수식으로 먹어버리는 사고 방지([[reference_katex_math_rendering]]).
|
||||
if(!(typeof window!=='undefined' && typeof renderKatexIn==='function' && window.katex)){
|
||||
s=s.replace(/\$([^$\n]*\\[^$\n]*)\$/g,(m,body)=>{
|
||||
let t=body;
|
||||
t=t.replace(/\\(?:text|mathrm|mathbf)\s*\{([^{}]*)\}/g,'$1');
|
||||
t=t.replace(/\\times/g,'×').replace(/\\approx/g,'≈').replace(/\\sim/g,'~')
|
||||
.replace(/\\pm/g,'±').replace(/\\cdot/g,'·').replace(/\\le(?:q)?\b/g,'≤')
|
||||
.replace(/\\ge(?:q)?\b/g,'≥').replace(/\\neq?\b/g,'≠')
|
||||
.replace(/\\to\b|\\rightarrow\b/g,'→').replace(/\\leftarrow\b/g,'←')
|
||||
.replace(/\\deg(ree)?\b/g,'°').replace(/\\mu\b/g,'μ').replace(/\\%/g,'%');
|
||||
t=t.replace(/\\[a-zA-Z]+/g,'').replace(/\\([{}])/g,'$1').trim();
|
||||
return t;
|
||||
});
|
||||
}
|
||||
// 리스트 — 연속하는 [-*] 줄을 하나의 <ul>로 묶는다(항목별 <ul>이 따로 생기던 버그 수정).
|
||||
s=s.replace(/(^|\n)([-*]\s+.+(?:\n[-*]\s+.+)*)/g,(m,lead,grp)=>{
|
||||
const items=grp.split('\n').map(line=>'<li>'+line.replace(/^[-*]\s+/,'')+'</li>').join('');
|
||||
return lead+'<ul style="padding-left:16px;margin:3px 0">'+items+'</ul>';
|
||||
});
|
||||
// 표 — 연속하는 파이프 라인(구분행 |---| 포함)을 <table>로. 표 지원이 없어 파이프 문자열이
|
||||
// 평문으로 뭉개져 한 줄에 다 붙어 보였다(09-24 실측: 카메라 모듈 스펙 표가 그대로 복사됨).
|
||||
// 구분행이 없으면 표가 아니라고 보고 그대로 둔다.
|
||||
s=s.replace(/(?:\|.*\n?)+/g, grp=>{
|
||||
const lines=grp.replace(/\n$/,'').split('\n').filter(l=>l.trim());
|
||||
if(lines.length<2 || !/^\|[\s:|-]+$/.test(lines[1])) return grp;
|
||||
const mkRow=(line,tag)=>{
|
||||
const cs=line.replace(/^\s*\||\|\s*$/g,'').split('|')
|
||||
.map(c=>'<'+tag+' style="border:1px solid var(--rb-border,#d5d0c8);padding:2px 8px;text-align:left;font-weight:'+(tag==='th'?'600':'400')+'">'+c.trim()+'</'+tag+'>').join('');
|
||||
return '<tr>'+cs+'</tr>';
|
||||
};
|
||||
return '<table style="border-collapse:collapse;margin:6px 0;font-size:12px">'
|
||||
+mkRow(lines[0],'th')+lines.slice(2).map(l=>mkRow(l,'td')).join('')+'</table>';
|
||||
});
|
||||
// 단락 — 빈 줄 단위로 split. 이미 블록 태그(<h3>/<ul>/코드 자리표시자)로 시작하면
|
||||
// <p>로 감싸지 않는다.
|
||||
s=s.split(/\n{2,}/).map(chunk=>{
|
||||
const t=chunk.trim();
|
||||
if(!t)return '';
|
||||
if(/^\u0000B\d+\u0000$/.test(t))return t;
|
||||
if(/^<(h3|ul|pre|table)\b/.test(t))return t;
|
||||
return '<p>'+t+'</p>';
|
||||
}).join('');
|
||||
s=s.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[+i]||'');
|
||||
return s;
|
||||
}
|
||||
|
||||
function chatGreeting(){
|
||||
// 프로젝트 이름은 addMsg→innerHTML로 들어가므로 반드시 이스케이프 — 이름에
|
||||
// <script>/태그 문자가 섞이면(예: 채팅으로 지은 프로젝트 이름) 채팅창에서 실행된다.
|
||||
return '🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+escHtml(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.';
|
||||
}
|
||||
|
||||
// 프로젝트 전환(수동/생성/삭제) 시 채팅창을 그 프로젝트의 세션 기록으로 리셋한다 —
|
||||
// 세션ID가 프로젝트별로 분리돼 있으므로 여기서 지우고 다시 당겨오는 것으로 충분하다.
|
||||
let _chatRefreshSeq=0;
|
||||
async function refreshChatForProject(){
|
||||
const seq=++_chatRefreshSeq;
|
||||
document.getElementById('chat-msgs').innerHTML='';
|
||||
await loadHistory();
|
||||
if(seq!==_chatRefreshSeq)return; // 그 사이 다른 프로젝트로 또 전환됨 — 인사말 중복 방지
|
||||
addMsg('system',chatGreeting());
|
||||
}
|
||||
|
||||
async function sendMessage(){
|
||||
const input=document.getElementById('chat-input');
|
||||
const msg=input.value.trim();
|
||||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||||
input.value='';autoResize(input);
|
||||
addMsg('user',escHtml(msg));
|
||||
await callChat(msg);
|
||||
}
|
||||
|
||||
function setProjectControlsBusy(busy){
|
||||
for(const el of document.querySelectorAll('#project-select,.rb-hdr .hdr-btn:not(#theme-btn)'))el.disabled=busy;
|
||||
}
|
||||
|
||||
async function callChat(message){
|
||||
const btn=document.getElementById('send-btn');
|
||||
btn.disabled=true;btn.textContent='...';
|
||||
// 응답을 받는 동안 프로젝트를 바꾸면 채팅창이 지워져 진행 중이던 답변이 안 보인다 — 잠근다.
|
||||
setProjectControlsBusy(true);
|
||||
const msgs=document.getElementById('chat-msgs');
|
||||
const typingDiv=document.createElement('div');typingDiv.className='rb-msg assistant';
|
||||
typingDiv.innerHTML='<div class="rb-typing"><div class="rb-dot"></div><div class="rb-dot"></div><div class="rb-dot"></div></div>';
|
||||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||||
let bubble=null,textBuf='';
|
||||
try{
|
||||
const body={
|
||||
message,sessionId:getSessionId(),useTools:true,
|
||||
skillContext:'이 대화는 "작업실"(여러 메이커 프로젝트 관리) 대시보드 페이지의 전용 채팅입니다. 현재 선택된 프로젝트: "'+(currentProject()?.name||'')+'". workshop_project 도구로 이 프로젝트의 부품/작업/메모를 바로 조회·수정하세요(project_name을 생략하면 이 프로젝트가 대상입니다. 다른 프로젝트를 다루려면 project_name을 명시하거나 set_active_project로 먼저 전환하세요).',
|
||||
};
|
||||
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||||
if(!r.ok)throw new Error('서버 오류 '+r.status);
|
||||
typingDiv.remove();
|
||||
const msgDiv=document.createElement('div');msgDiv.className='rb-msg assistant';
|
||||
bubble=document.createElement('div');bubble.className='rb-bubble';
|
||||
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
|
||||
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
|
||||
while(true){
|
||||
const{done,value}=await reader.read();if(done)break;
|
||||
buf+=dec.decode(value,{stream:true});
|
||||
// 전역 부품 배열 parts를 가리지 않게 SSE 조각은 sseParts로.
|
||||
const sseParts=buf.split('\n\n');buf=sseParts.pop()||'';
|
||||
for(const part of sseParts){
|
||||
if(!part.startsWith('data: '))continue;
|
||||
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
|
||||
if(ev.type==='token'||ev.type==='text_delta'){
|
||||
textBuf+=(ev.text||'');
|
||||
if(bubble){bubble.innerHTML=renderMd(textBuf);if(typeof renderKatexIn==='function')renderKatexIn(bubble);msgs.scrollTop=msgs.scrollHeight;}
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(e){
|
||||
typingDiv.remove();
|
||||
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
|
||||
}finally{btn.disabled=false;btn.textContent='전송';setProjectControlsBusy(false);}
|
||||
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
|
||||
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
|
||||
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.
|
||||
await softRefreshWorkshop();
|
||||
// 09-22: 채팅으로 scad_to_stl 등 파일도구를 불러 새 파일이 생겨도 파일탭이 이미 열려있으면
|
||||
// 안 새로고침되던 문제(사용자 신고: "저장되었는데 보이지 않네") — 탭 전환 없이도 갱신.
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
|
||||
async function clearChat(){
|
||||
const sid=getSessionId();
|
||||
document.getElementById('chat-msgs').innerHTML='';
|
||||
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||||
addMsg('system','새 대화가 시작되었습니다.');
|
||||
}
|
||||
|
||||
// ── 사진 첨부(📷) ─────────────────────────────────────────────────────────────
|
||||
// 이 프로젝트의 사진을 골라 채팅 입력에 "[첨부 사진: 경로]"를 넣는다. 서버가 이 표시를 보면
|
||||
// workshop_project view_image로 그 사진을 모델이 직접 보게 한다(비전 모델일 때).
|
||||
async function toggleAttachPop(ev){
|
||||
ev.stopPropagation();
|
||||
const pop=document.getElementById('attach-pop');
|
||||
if(pop.classList.contains('open')){pop.classList.remove('open');return;}
|
||||
pop.classList.add('open');
|
||||
pop.innerHTML='<div class="rb-empty">불러오는 중…</div>';
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
let photos=[];
|
||||
try{
|
||||
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
const d=await r.json();
|
||||
photos=(d.files||[]).filter(f=>f.category==='photo').slice(-30).reverse();
|
||||
}catch{}
|
||||
pop.innerHTML='<label class="rb-mini-btn" style="display:block;text-align:center;cursor:pointer">+ 새 사진 올려서 첨부<input type="file" accept="image/*" style="display:none" onchange="attachUpload(event)"></label>'
|
||||
+(photos.length
|
||||
?'<div class="rb-attach-grid">'+photos.map(f=>'<img src="'+escAttr(f.url)+'" title="'+escAttr(f.relPath)+'" loading="lazy" onclick="attachPhoto(\''+escJs(f.relPath)+'\')">').join('')+'</div>'
|
||||
:'<div class="rb-empty">이 프로젝트에 올린 사진이 없습니다.</div>');
|
||||
}
|
||||
function attachPhoto(relPath){
|
||||
const inp=document.getElementById('chat-input');
|
||||
inp.value=(inp.value?inp.value.replace(/\s+$/,'')+' ':'')+'[첨부 사진: '+relPath+'] ';
|
||||
autoResize(inp);inp.focus();
|
||||
document.getElementById('attach-pop').classList.remove('open');
|
||||
}
|
||||
async function attachUpload(ev){
|
||||
const file=ev.target.files&&ev.target.files[0];
|
||||
ev.target.value='';
|
||||
const proj=currentProject();
|
||||
if(!file||!proj)return;
|
||||
const pop=document.getElementById('attach-pop');
|
||||
pop.innerHTML='<div class="rb-empty">업로드 중… '+escHtml(file.name)+'</div>';
|
||||
try{
|
||||
const fd=new FormData();fd.append('file',file);
|
||||
const r=await fetch('/api/workshop/upload?projectId='+encodeURIComponent(proj.id)+'&folder='+encodeURIComponent('사진'),{method:'POST',headers:authH(),body:fd});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok||!d.relPath)throw new Error(d.error||'업로드 실패');
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
attachPhoto(d.relPath);
|
||||
}catch(e){pop.innerHTML='<div class="rb-empty">업로드 실패: '+escHtml(e.message)+'</div>';}
|
||||
}
|
||||
document.addEventListener('click',(e)=>{
|
||||
const w=document.querySelector('.rb-attach-wrap');
|
||||
if(w&&!w.contains(e.target))document.getElementById('attach-pop')?.classList.remove('open');
|
||||
});
|
||||
|
||||
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
||||
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
|
||||
|
||||
// ── 채팅 패널 리사이즈 ────────────────────────────────────────────────────────
|
||||
(function(){
|
||||
const CHAT_W='rb_chat_width';
|
||||
const handle=document.getElementById('resize-handle');
|
||||
const panel=document.getElementById('chat-panel');
|
||||
let dragging=false,startX=0,startW=0;
|
||||
panel.style.width=(parseInt(localStorage.getItem(CHAT_W)||'340',10))+'px';
|
||||
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
|
||||
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
|
||||
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
|
||||
})();
|
||||
|
||||
// ── 모델 pill ─────────────────────────────────────────────────────────────────
|
||||
// 경량 버전: 메인 채팅의 설정 모달(settings-provider.js)을 통째로 들여오지 않고, 같은 백엔드
|
||||
// 엔드포인트(/api/settings/model, /api/settings/provider, /api/models/test)만 재사용해서
|
||||
// 전역 models.primary를 바꾼다. ollama provider(로컬+클라우드 모델 태그) 안에서만 전환됨 —
|
||||
// provider 자체(openai/anthropic/google 등)를 바꾸는 건 메인 설정에서 해야 한다.
|
||||
let _modelDropdownLoaded=false;
|
||||
async function loadModelPill(){
|
||||
try{
|
||||
const r=await fetch('/api/settings/model',{headers:authH()});
|
||||
if(!r.ok)return;
|
||||
const d=await r.json();
|
||||
const btn=document.getElementById('model-pill-btn');
|
||||
if(btn && d.primary) btn.textContent=d.primary;
|
||||
}catch{}
|
||||
}
|
||||
async function toggleModelDropdown(ev){
|
||||
if(ev) ev.stopPropagation();
|
||||
const dd=document.getElementById('model-dropdown');
|
||||
if(dd.classList.contains('open')){dd.classList.remove('open');return;}
|
||||
dd.classList.add('open');
|
||||
if(_modelDropdownLoaded)return;
|
||||
dd.innerHTML='<div class="rb-model-dropdown-msg">모델 불러오는 중…</div>';
|
||||
try{
|
||||
const pr=await fetch('/api/settings/provider',{headers:authH()});
|
||||
const pd=await pr.json();
|
||||
const tr=await fetch('/api/models/test',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({llm:pd.llm})});
|
||||
const td=await tr.json();
|
||||
const models=(td.models||[]).map(m=>typeof m==='string'?m:(m.name||String(m)));
|
||||
if(!models.length){dd.innerHTML='<div class="rb-model-dropdown-msg">모델 목록을 가져오지 못했습니다.</div>';return;}
|
||||
const current=(document.getElementById('model-pill-btn').textContent||'').trim();
|
||||
dd.innerHTML=models.map(m=>`<div class="rb-model-opt${m===current?' current':''}" data-model="${escHtml(m)}">${escHtml(m)}</div>`).join('');
|
||||
dd.querySelectorAll('.rb-model-opt').forEach(el=>el.addEventListener('click',()=>selectModel(el.dataset.model)));
|
||||
_modelDropdownLoaded=true;
|
||||
}catch(e){dd.innerHTML='<div class="rb-model-dropdown-msg">오류: '+escHtml(e.message)+'</div>';}
|
||||
}
|
||||
async function selectModel(name){
|
||||
document.getElementById('model-dropdown').classList.remove('open');
|
||||
const btn=document.getElementById('model-pill-btn');
|
||||
const prev=btn.textContent;
|
||||
if(name===prev)return;
|
||||
btn.textContent=name;
|
||||
try{
|
||||
const r=await fetch('/api/settings/model',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({primary:name})});
|
||||
if(!r.ok)throw new Error('저장 실패');
|
||||
_modelDropdownLoaded=false;
|
||||
}catch(e){
|
||||
btn.textContent=prev;
|
||||
addMsg('system','모델 변경 실패: '+escHtml(e.message));
|
||||
}
|
||||
}
|
||||
document.addEventListener('click',(e)=>{
|
||||
const pill=document.querySelector('.rb-model-pill');
|
||||
if(pill && !pill.contains(e.target)) document.getElementById('model-dropdown')?.classList.remove('open');
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
// 작업실 앱 — 핵심(인증·테마·이스케이프·상태/저장/충돌/폴링·탭·지연 로더·모달/이력). 2026-09-25 workshop-app.js를 기능별로 분리(내용 변경 없음).
|
||||
// classic script — 전역 함수/변수를 공유한다. HTML에서 core → parts → tasks → files → chat → init 순서로 로드(원래 한 파일의 순서 그대로).
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
const TOKEN_KEY='smallclaw_token';
|
||||
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||||
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
|
||||
async function checkAuth(){
|
||||
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||||
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
|
||||
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||||
}
|
||||
|
||||
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}const tb=document.getElementById('theme-btn');if(tb)tb.textContent=n==='light'?'☀️':'🌙';}
|
||||
|
||||
// 속성값·텍스트 노드 양쪽에 쓰인다 — 예전엔 따옴표만 이스케이프해서 텍스트 노드에 들어간
|
||||
// 부품/프로젝트/파일 이름의 <img onerror=...> 가 그대로 실행됐다(09-24 검토). & < > 도 처리.
|
||||
function escAttr(s){return escHtml(s);}
|
||||
// onclick="fn('...')" 안의 JS 문자열 컨텍스트용 — escAttr의 HTML 엔티티(')는
|
||||
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
|
||||
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
|
||||
function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>').replace(/\r?\n/g,'\\n');}
|
||||
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
|
||||
|
||||
// 텍스트/속성/링크 이스케이프 — 여러 모듈이 공유(2026-09-25: 채팅 섹션에서 core로 이동)
|
||||
function escHtml(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
// 부품 참고링크 href용 — http(s)(프로토콜 상대 포함)만 통과시키고 나머지는 빈 값으로
|
||||
// 뭉갠다. javascript:/data: 스킴 링크는 채팅 도구(add_link)나 오타로도 들어올 수 있고
|
||||
// href에 그대로 쓰면 클릭 한 번에 스크립트가 돈다.
|
||||
function safeUrl(u){u=String(u??'').trim();return /^(https?:|\/\/)/i.test(u)?u:'';}
|
||||
|
||||
// ── 상태 ──────────────────────────────────────────────────────────────────────
|
||||
// "작업실"은 여러 프로젝트를 담는다(workshopData.projects) — 화면엔 그중 하나
|
||||
// (activeProjectId)만 표시하고, parts/phases/notes는 그 프로젝트의 내용을 가리키는
|
||||
// 지역 변수다(기존 로봇전용 시절 코드를 최대한 그대로 재사용하기 위한 구조 — 함수 대부분은
|
||||
// parts/phases/notes를 그대로 조작하고, 저장 직전에만 현재 프로젝트 객체로 동기화한다).
|
||||
let workshopData={projects:[],activeProjectId:''};
|
||||
let parts=[];
|
||||
let phases=[];
|
||||
let notes='';
|
||||
let overviewDesc='';
|
||||
let budget=0; // 목표 예산(원, 0=미설정)
|
||||
let assembly=null; // 조립 설명서 명세(없으면 null) — project.assembly와 같은 객체
|
||||
let showArchived=false;
|
||||
try{showArchived=localStorage.getItem('ws_show_archived')==='1';}catch{}
|
||||
let conflict=null; // {id,server} — 다른 곳에서 먼저 수정돼 저장이 보류된 프로젝트
|
||||
let saveTimer=null;
|
||||
let saveDebounce=null;
|
||||
// 저장 개편(2026-09-24): 전체문서 PUT 대신 바뀐 프로젝트만 개별 PUT한다 — 서버가
|
||||
// 디스크의 현재 문서에 해당 프로젝트만 upsert하므로, 채팅 도구가 다른 프로젝트를
|
||||
// 동시에 저장해도 한쪽 편집이 통째로 사라지는 일이 없다. 어디가 더러운지는
|
||||
// dirtyProjects로 추적하고 saveProject()가 그 대상만 순차 전송한다.
|
||||
const dirtyProjects=new Set();
|
||||
// 서버와 마지막으로 동기화된 프로젝트 JSON(id→문자열). 실제로 바뀐 프로젝트만 dirty로 잡기
|
||||
// 위한 기준 — 이게 없으면 saveProject가 매번 현재 프로젝트를 무조건 PUT해서, 화면이 옛
|
||||
// 상태일 때 채팅 도구가 서버에 쓴 내용을 덮어썼다(09-24 검토에서 재현).
|
||||
const savedJson=new Map();
|
||||
function normalizeProjects(list){
|
||||
for(const p of list){
|
||||
p.notes=typeof p.notes==='string'?p.notes:'';
|
||||
p.description=typeof p.description==='string'?p.description:'';
|
||||
p.budget=Number(p.budget)||0;
|
||||
p.archived=p.archived===true;
|
||||
if(!Array.isArray(p.parts))p.parts=[];
|
||||
if(!Array.isArray(p.phases))p.phases=[];
|
||||
for(const ph of p.phases)if(!Array.isArray(ph.tasks))ph.tasks=[];
|
||||
for(const pt of p.parts)if(!Array.isArray(pt.links))pt.links=[];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
function snapshotProjects(){
|
||||
savedJson.clear();
|
||||
for(const p of workshopData.projects)savedJson.set(p.id,JSON.stringify(p));
|
||||
}
|
||||
|
||||
function currentProject(){
|
||||
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
|
||||
}
|
||||
|
||||
// STL 선택은 프로젝트 기준 relPath를 담는다 — 프로젝트가 바뀌면(수동 전환/생성/삭제,
|
||||
// 채팅 도구의 set_active_project 포함) 반드시 비운다. 안 그러면 옛 프로젝트 파일의
|
||||
// relPath가 workshopFilePath()에서 새 프로젝트 경로로 재해석되어, 같은 이름의 다른
|
||||
// 파일이 슬라이서로 잘못 전달될 수 있다.
|
||||
function clearStlSelection(){
|
||||
stlSelection.clear();
|
||||
const btn=document.getElementById('stl-slice-btn');
|
||||
const countEl=document.getElementById('stl-sel-count');
|
||||
if(countEl)countEl.textContent='0';
|
||||
if(btn)btn.disabled=true;
|
||||
}
|
||||
|
||||
function loadLocalFromProject(){
|
||||
const proj=currentProject();
|
||||
parts=proj?(proj.parts||[]):[];
|
||||
phases=proj?(proj.phases||[]):[];
|
||||
notes=proj?(proj.notes||''):'';
|
||||
overviewDesc=proj?(proj.description||''):'';
|
||||
budget=proj?(Number(proj.budget)||0):0;
|
||||
assembly=proj&&proj.assembly?proj.assembly:null;
|
||||
}
|
||||
|
||||
function syncLocalToProject(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
proj.parts=parts;
|
||||
proj.phases=phases;
|
||||
proj.notes=notes;
|
||||
proj.description=overviewDesc;
|
||||
proj.budget=budget;
|
||||
// 빈 조립 명세(경로도 부품도 단계도 없음)는 저장하지 않는다 — 조립 탭을 열기만 해도 프로젝트에 빈 명세가 남던 문제
|
||||
if(assembly&&(assembly.scad||assembly.parts.length||assembly.steps.length))proj.assembly=assembly;else delete proj.assembly;
|
||||
if(savedJson.get(proj.id)!==JSON.stringify(proj))dirtyProjects.add(proj.id);
|
||||
}
|
||||
|
||||
function renderProjectSelector(){
|
||||
const sel=document.getElementById('project-select');
|
||||
const list=workshopData.projects.filter(p=>showArchived||!p.archived||p.id===workshopData.activeProjectId);
|
||||
sel.innerHTML=list.map(p=>`<option value="${escAttr(p.id)}" ${p.id===workshopData.activeProjectId?'selected':''}>${p.archived?'📦 ':''}${escAttr(p.name)}</option>`).join('');
|
||||
const label=document.getElementById('chat-label');
|
||||
const proj=currentProject();
|
||||
if(label&&proj)label.textContent='🛠️ '+proj.name+' 채팅';
|
||||
const ab=document.getElementById('archive-btn');
|
||||
if(ab&&proj)ab.textContent=proj.archived?'📦 보관 해제':'📦 보관';
|
||||
const sa=document.getElementById('show-archived');
|
||||
if(sa)sa.checked=showArchived;
|
||||
}
|
||||
|
||||
function toggleShowArchived(on){
|
||||
showArchived=!!on;
|
||||
try{localStorage.setItem('ws_show_archived',on?'1':'0');}catch{}
|
||||
renderProjectSelector();
|
||||
}
|
||||
|
||||
// 보관 = 삭제와 달리 데이터·파일을 그대로 두고 프로젝트 목록에서만 숨긴다.
|
||||
function toggleArchiveProject(){
|
||||
syncLocalToProject();
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const next=workshopData.projects.find(p=>p.id!==proj.id&&!p.archived);
|
||||
if(!proj.archived&&!next){alert('보관하지 않은 프로젝트가 하나는 남아 있어야 합니다.');return;}
|
||||
proj.archived=!proj.archived;
|
||||
syncLocalToProject(); // 바뀐 archived를 dirty로 반영
|
||||
if(proj.archived&&!showArchived){
|
||||
scheduleSave();
|
||||
switchProject(next.id);
|
||||
return;
|
||||
}
|
||||
renderProjectSelector();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function flushNow(){
|
||||
if(saveDebounce){clearTimeout(saveDebounce);saveDebounce=null;}
|
||||
await saveProject();
|
||||
}
|
||||
|
||||
async function duplicateProjectPrompt(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
if(!confirm(`"${proj.name}" 프로젝트를 복제할까요?\n구조(부품/단계/작업/메모/예산)는 그대로 가져오고, 작업 완료 표시와 부품 상태(주문/보유)는 초기화됩니다.`))return;
|
||||
await flushNow();
|
||||
try{
|
||||
const r=await fetch('/api/workshop/project/'+encodeURIComponent(proj.id)+'/duplicate',{method:'POST',headers:authH()});
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
}catch{alert('복제 실패');return;}
|
||||
await softRefreshWorkshop(); // 서버가 복제본을 활성화했으므로 전환까지 처리된다
|
||||
}
|
||||
|
||||
function refreshAllPanes(){
|
||||
expandedParts.clear();
|
||||
renderProjectSelector();
|
||||
renderParts();
|
||||
renderPhases();
|
||||
document.getElementById('notes-textarea').value=notes;
|
||||
document.getElementById('overview-textarea').value=overviewDesc;
|
||||
syncBudgetInput();
|
||||
updateBudget();
|
||||
refreshAssemblyPane();
|
||||
}
|
||||
|
||||
function switchProject(id){
|
||||
syncLocalToProject();
|
||||
workshopData.activeProjectId=id;
|
||||
loadLocalFromProject();
|
||||
clearStlSelection();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
putActive();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
|
||||
function createProjectPrompt(){
|
||||
// 새 프로젝트로 넘어가기 전 지금 편집 중인 내용을 직전 프로젝트에 반영 —
|
||||
// 안 그러면 loadLocalFromProject()가 지역변수를 새 빈 프로젝트로 덮어써서
|
||||
// 600ms debounce 안 끝난 편집이 유실된다(switchProject와 동일 순서).
|
||||
syncLocalToProject();
|
||||
const name=(prompt('새 프로젝트 이름을 입력하세요:')||'').trim();
|
||||
if(!name)return;
|
||||
const id=genId('proj');
|
||||
workshopData.projects.push({id,name,parts:[],phases:[],notes:'',description:''});
|
||||
workshopData.activeProjectId=id;
|
||||
dirtyProjects.add(id); // 새 프로젝트도 서버에 upsert돼야 함 — 직전 프로젝트는 sync가 dirty 마킹
|
||||
loadLocalFromProject();
|
||||
clearStlSelection();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
putActive();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
|
||||
function deleteProjectPrompt(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
|
||||
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 부품/작업/메모와 첨부파일, 이 프로젝트의 채팅 기록이 모두 지워지며 되돌릴 수 없습니다.`))return;
|
||||
dirtyProjects.delete(proj.id);
|
||||
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
|
||||
workshopData.activeProjectId=(workshopData.projects.find(p=>!p.archived)||workshopData.projects[0]).id;
|
||||
// 삭제는 프로젝트 단위 저장 API로 직접 반영 — 저장(debounce)이 못 건드리는
|
||||
// 이미 사라진 프로젝트를 PUT하면 404 소음만 생긴다.
|
||||
fetch('/api/workshop/project/'+encodeURIComponent(proj.id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
||||
loadLocalFromProject();
|
||||
clearStlSelection();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
putActive();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
|
||||
async function loadWorkshopData(){
|
||||
try{
|
||||
const r=await fetch('/api/workshop/data',{headers:authH()});
|
||||
if(r.ok){
|
||||
const d=await r.json();
|
||||
workshopData.projects=normalizeProjects(Array.isArray(d.projects)?d.projects:[]);
|
||||
workshopData.activeProjectId=d.activeProjectId||(workshopData.projects[0]?.id||'');
|
||||
}
|
||||
}catch{}
|
||||
if(!workshopData.projects.length){
|
||||
const id=genId('proj');
|
||||
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:'',description:''}];
|
||||
workshopData.activeProjectId=id;
|
||||
dirtyProjects.add(id); // 서버에 아직 없는 자리표시 프로젝트 — 첫 저장 때 서버에도 만든다
|
||||
}
|
||||
snapshotProjects();
|
||||
loadLocalFromProject();
|
||||
refreshAllPanes();
|
||||
}
|
||||
|
||||
function scheduleSave(){
|
||||
clearTimeout(saveDebounce);
|
||||
saveDebounce=setTimeout(saveProject,600);
|
||||
}
|
||||
// 타이머가 발동하면 saveDebounce를 반드시 비운다 — 옛 timeout id가 남아 있으면 "저장 대기
|
||||
// 중"으로 오판해서, 채팅 응답 후 softRefreshWorkshop이 서버 값을 받기 전에 화면의 옛 상태를
|
||||
// 먼저 PUT해 채팅이 저장한 내용을 지웠다.
|
||||
|
||||
// 탭 닫기/새로고침 시 대기 중인 저장을 즉시 flush — 600ms debounce가 끝나기 전에
|
||||
// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어
|
||||
// keepalive:true로 브라우저에 전송을 보장받는다. beforeunload(닫기 직전)와
|
||||
// pagehide(실제 내비게이션 확정) 둘 다에 걸고, saveDebounce 널 처리로 이중 실행을 막는다.
|
||||
// 저장 개편에 맞춰 더러운 프로젝트마다 개별 upsert로 보낸다(응답을 기다리지 않음).
|
||||
function flushPendingSave(){
|
||||
if(!saveDebounce)return;
|
||||
clearTimeout(saveDebounce);saveDebounce=null;
|
||||
syncLocalToProject();
|
||||
for(const id of dirtyProjects){
|
||||
const p=workshopData.projects.find(x=>x.id===id);
|
||||
if(!p)continue;
|
||||
if(conflict)continue; // 충돌 보류 중엔 unload 저장도 서버 내용을 덮지 않는다
|
||||
fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p),keepalive:true});
|
||||
}
|
||||
dirtyProjects.clear();
|
||||
}
|
||||
window.addEventListener('beforeunload',flushPendingSave);
|
||||
window.addEventListener('pagehide',flushPendingSave);
|
||||
|
||||
// 채팅 응답 후 호출: 서버 데이터를 다시 당겨오되, 내용이 안 바뀌었으면 화면을 건드리지
|
||||
// 않는다(편집 중 포커스/펼침 카드 보존). 바뀌었으면 살아있는 부품에 한해 펼침 상태를
|
||||
// 유지한 채 다시 그린다. loadWorkshopData(초기 로딩용, 무조건 전체 새로고침)와 다른 용도.
|
||||
// 사용자가 대시보드 입력칸을 편집 중인가 — 폴링 갱신이 입력칸을 다시 그려 포커스를 날리면 안 된다.
|
||||
function userIsEditing(){
|
||||
const a=document.activeElement;
|
||||
if(!a||a===document.body||a.id==='chat-input')return false;
|
||||
return /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)&&!!a.closest('.rb-body,.rb-budget');
|
||||
}
|
||||
function modalOpen(){return document.getElementById('modal-bg').classList.contains('open');}
|
||||
|
||||
// opts.poll: 주기적 백그라운드 갱신(다른 탭/채팅 경로의 변경 반영). 편집 중이거나 저장 대기·충돌
|
||||
// 보류·모달·채팅 응답 중이면 건드리지 않고 다음 주기로 미룬다.
|
||||
async function softRefreshWorkshop(opts){
|
||||
const poll=!!(opts&&opts.poll);
|
||||
if(poll){
|
||||
if(conflict||saveDebounce||dirtyProjects.size||userIsEditing()||modalOpen())return;
|
||||
if(document.getElementById('send-btn').disabled)return;
|
||||
}
|
||||
// 대기 중인 저장(debounce)이 있으면 먼저 flush — 안 그러면 방금 편집한 내용이 아직
|
||||
// 서버에 안 간 상태에서 서버 데이터를 당겨오면 그 편집이 옛 데이터에 덮어씌워 사라진다.
|
||||
if(saveDebounce){clearTimeout(saveDebounce);saveDebounce=null;await saveProject();}
|
||||
if(conflict)return; // 충돌 해결 전엔 화면을 서버 값으로 바꾸지 않는다
|
||||
try{
|
||||
const r=await fetch('/api/workshop/data',{headers:authH()});
|
||||
if(!r.ok)return;
|
||||
const d=await r.json();
|
||||
const newProjects=normalizeProjects(Array.isArray(d.projects)?d.projects:[]);
|
||||
const newActive=d.activeProjectId||(newProjects[0]?.id||'');
|
||||
if(JSON.stringify(newProjects)===JSON.stringify(workshopData.projects)&&newActive===workshopData.activeProjectId)return;
|
||||
const activeChanged=newActive!==workshopData.activeProjectId;
|
||||
workshopData.projects=newProjects;
|
||||
workshopData.activeProjectId=newActive;
|
||||
snapshotProjects();
|
||||
loadLocalFromProject();
|
||||
const liveIds=new Set(parts.map(p=>p.id));
|
||||
for(const id of [...expandedParts])if(!liveIds.has(id))expandedParts.delete(id);
|
||||
renderProjectSelector();
|
||||
renderParts();
|
||||
renderPhases();
|
||||
const ta=document.getElementById('notes-textarea');
|
||||
if(ta&&ta.value!==notes)ta.value=notes;
|
||||
const ota=document.getElementById('overview-textarea');
|
||||
if(ota&&ota.value!==overviewDesc)ota.value=overviewDesc;
|
||||
syncBudgetInput();
|
||||
updateBudget();
|
||||
refreshAssemblyPane();
|
||||
if(activeChanged){
|
||||
// 채팅에서 set_active_project/create_project로 프로젝트가 바뀐 경우 —
|
||||
// switchProject와 같은 정리를 해줘야 한다. 안 그러면 옛 프로젝트에서
|
||||
// 선택해둔 STL이 그대로 남아 슬라이서에 잘못 전송되고, 채팅창도 옛
|
||||
// 프로젝트의 세션 기록을 보여준다. 매 응답마다가 아니라 실제 전환 시에만.
|
||||
clearStlSelection();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// 저장은 반드시 한 번에 하나씩(직렬화) — 자동 흐름(초안→렌더의 flushNow)과 debounce 타이머가 동시에 saveProject를
|
||||
// 부르면 둘 다 같은 기준 버전(updatedAt)으로 PUT해서, 두 번째가 "첫 번째 저장이 만든 새 버전"과 어긋나 가짜 충돌(409)로
|
||||
// 잡혔다(09-25 "렌더 실패: 저장 충돌을 먼저 해결하세요" 신고). 큐에 태우면 두 번째는 갱신된 버전/스냅샷을 보고 무변경이면 PUT을 건너뛴다.
|
||||
let saveQueue=Promise.resolve();
|
||||
function saveProject(){
|
||||
saveDebounce=null;
|
||||
const run=saveQueue.then(_saveProject,_saveProject);
|
||||
saveQueue=run.catch(()=>{});
|
||||
return run;
|
||||
}
|
||||
async function _saveProject(){
|
||||
saveDebounce=null;
|
||||
syncLocalToProject();
|
||||
const ids=[...dirtyProjects];
|
||||
dirtyProjects.clear();
|
||||
if(!ids.length)return;
|
||||
try{
|
||||
// 바뀐 프로젝트만 개별 upsert — 다른 프로젝트는 서버 디스크 값 그대로 보존.
|
||||
for(const id of ids){
|
||||
const p=workshopData.projects.find(x=>x.id===id);
|
||||
if(!p)continue; // 이번 턴에 삭제된 프로젝트 — deleteProjectPrompt가 서버 DELETE로 처리
|
||||
if(conflict){dirtyProjects.add(id);continue;} // 충돌 해결 전엔 아무것도 서버로 보내지 않는다
|
||||
const body=JSON.stringify(p);
|
||||
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body});
|
||||
if(r.status===409){
|
||||
// 낙관적 락: 내가 본 버전 이후 다른 곳(채팅 도구/다른 탭)이 이 프로젝트를 고쳤다 — 조용히 덮지 않는다.
|
||||
const d=await r.json().catch(()=>({}));
|
||||
// 단, 서버 내용이 내 내용과 똑같으면(내 이전 저장이 만든 버전) 충돌이 아니다 — 버전만 받아들이고 넘어간다.
|
||||
const strip=x=>JSON.stringify({...x,updatedAt:undefined});
|
||||
if(d.server&&strip(d.server)===strip(p)){
|
||||
p.updatedAt=d.server.updatedAt;
|
||||
savedJson.set(id,JSON.stringify(p));
|
||||
continue;
|
||||
}
|
||||
dirtyProjects.add(id);
|
||||
showConflict(id,d.server);
|
||||
continue;
|
||||
}
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
const res=await r.json().catch(()=>({}));
|
||||
if(res.updatedAt)p.updatedAt=res.updatedAt;
|
||||
savedJson.set(id,JSON.stringify(p));
|
||||
}
|
||||
if(!conflict)showSaveIndicator('저장됨');
|
||||
}catch{
|
||||
// 실패한 프로젝트는 dirty로 되돌려 다음 저장에서 재시도한다(안 그러면 조용히 유실).
|
||||
for(const id of ids)if(savedJson.get(id)!==JSON.stringify(workshopData.projects.find(x=>x.id===id)))dirtyProjects.add(id);
|
||||
showSaveIndicator('저장 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function showConflict(id,serverProj){
|
||||
if(conflict)return;
|
||||
conflict={id,server:serverProj};
|
||||
const p=workshopData.projects.find(x=>x.id===id);
|
||||
const el=document.getElementById('conflict-banner');
|
||||
el.innerHTML='⚠ <b>'+escHtml(p?p.name:'프로젝트')+'</b> 이(가) 다른 곳(채팅 도구/다른 탭)에서 먼저 수정되어 저장을 보류했습니다. '
|
||||
+'<button onclick="resolveConflict(\'mine\')">내 변경으로 덮어쓰기</button>'
|
||||
+'<button onclick="resolveConflict(\'server\')">서버 버전 불러오기(내 변경 버림)</button>';
|
||||
el.style.display='flex';
|
||||
}
|
||||
function clearConflict(){
|
||||
conflict=null;
|
||||
const el=document.getElementById('conflict-banner');
|
||||
el.style.display='none';el.innerHTML='';
|
||||
}
|
||||
async function resolveConflict(mode){
|
||||
if(!conflict)return;
|
||||
const {id,server}=conflict;
|
||||
const idx=workshopData.projects.findIndex(x=>x.id===id);
|
||||
if(idx<0){clearConflict();return;}
|
||||
if(mode==='mine'){
|
||||
if(id===workshopData.activeProjectId)syncLocalToProject();
|
||||
const p=workshopData.projects[idx];
|
||||
try{
|
||||
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id)+'?force=1',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p)});
|
||||
if(!r.ok)throw new Error();
|
||||
const res=await r.json().catch(()=>({}));
|
||||
if(res.updatedAt)p.updatedAt=res.updatedAt;
|
||||
savedJson.set(id,JSON.stringify(p));
|
||||
dirtyProjects.delete(id);
|
||||
clearConflict();
|
||||
showSaveIndicator('덮어씀');
|
||||
}catch{alert('덮어쓰기 실패');}
|
||||
return;
|
||||
}
|
||||
// 서버 버전 채택
|
||||
const fresh=normalizeProjects([server||workshopData.projects[idx]])[0];
|
||||
workshopData.projects[idx]=fresh;
|
||||
savedJson.set(id,JSON.stringify(fresh));
|
||||
dirtyProjects.delete(id);
|
||||
clearConflict();
|
||||
if(id===workshopData.activeProjectId){loadLocalFromProject();refreshAllPanes();}
|
||||
else renderProjectSelector();
|
||||
}
|
||||
|
||||
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
|
||||
async function putActive(){
|
||||
try{
|
||||
await fetch('/api/workshop/active',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({activeProjectId:workshopData.activeProjectId})});
|
||||
}catch{}
|
||||
}
|
||||
|
||||
function showSaveIndicator(msg){
|
||||
const el=document.getElementById('save-indicator');
|
||||
if(!el)return;
|
||||
el.textContent=msg;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer=setTimeout(()=>{el.textContent='';},2000);
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
function switchTab(tab){
|
||||
document.querySelectorAll('.rb-tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
|
||||
document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active'));
|
||||
document.getElementById('pane-'+tab).classList.add('active');
|
||||
// 장비 탭 위젯은 지연 로딩 모듈(workshop-equip.js) — 다른 탭으로 나가면 로드돼 있을 때만 정리한다.
|
||||
if(tab==='equip')withLazyModule('equip','pane-equip',()=>startEquipTimers());
|
||||
else if(typeof stopEquipTimers==='function')stopEquipTimers();
|
||||
if(tab==='files')loadFiles();
|
||||
if(tab==='assembly')refreshAssemblyPane(true);
|
||||
}
|
||||
|
||||
// ── 지연 로딩 모듈(2026-09-25) ────────────────────────────────────────────────
|
||||
// 조립 설명서 탭(22KB)과 장비 탭(12KB)은 처음 열 때만 받는다 — 시작 로딩에서 약 34KB(30%)를 뺀다.
|
||||
// 별개 classic script라 이 파일의 전역 let/함수를 그대로 공유하고, 이 파일에서 그쪽 이름을 직접 부르는 곳은
|
||||
// 아래 shim(refreshAssemblyPane)과 switchTab의 장비 호출뿐이다(로드 전엔 부르지 않도록 가드).
|
||||
const lazyModules={}; // name → Promise
|
||||
const lazyLoaded={}; // name → true(로드 완료)
|
||||
function loadWorkshopModule(name){
|
||||
if(lazyModules[name])return lazyModules[name];
|
||||
lazyModules[name]=new Promise((resolve,reject)=>{
|
||||
const s=document.createElement('script');
|
||||
s.src='../js/app/workshop-'+name+'.js';
|
||||
s.onload=()=>{lazyLoaded[name]=true;resolve();};
|
||||
s.onerror=()=>{delete lazyModules[name];s.remove();reject(new Error(name+' 모듈을 불러오지 못했습니다(네트워크/서버 확인 후 탭을 다시 눌러 주세요)'));};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return lazyModules[name];
|
||||
}
|
||||
// 모듈이 로드된 뒤 fn 실행. 로드 중엔 패널을 흐리게(.rb-loading) 눌러 클릭을 막고, 그 사이 다른 탭으로 옮겼으면 실행하지 않는다.
|
||||
async function withLazyModule(name,paneId,fn){
|
||||
const pane=document.getElementById(paneId);
|
||||
try{
|
||||
if(!lazyLoaded[name]){pane.classList.add('rb-loading');await loadWorkshopModule(name);}
|
||||
pane.classList.remove('rb-loading');
|
||||
if(pane.classList.contains('active'))fn();
|
||||
}catch(e){
|
||||
pane.classList.remove('rb-loading');
|
||||
if(!lazyLoaded[name]){const box=document.createElement('div');box.className='rb-empty';box.textContent=e.message;pane.prepend(box);setTimeout(()=>box.remove(),6000);}
|
||||
else console.error(e);
|
||||
}
|
||||
}
|
||||
// 조립 탭 갱신 shim — 핵심 코드(상태 갱신/폴링/탭 전환)는 이 이름만 부른다. 탭이 열려 있을 때만 모듈을 로드한다.
|
||||
async function refreshAssemblyPane(forceLoad){
|
||||
const pane=document.getElementById('pane-assembly');
|
||||
if(!pane||!pane.classList.contains('active'))return;
|
||||
if(!lazyLoaded.assembly&&!pane.firstChild)pane.innerHTML='<div class="rb-empty">불러오는 중…</div>';
|
||||
await withLazyModule('assembly','pane-assembly',()=>refreshAssemblyPaneImpl(forceLoad));
|
||||
}
|
||||
|
||||
|
||||
// ── 모달 / 변경 이력 ─────────────────────────────────────────────────────────
|
||||
function openModal(title,html){
|
||||
document.getElementById('modal-title').textContent=title;
|
||||
document.getElementById('modal-body').innerHTML=html;
|
||||
document.getElementById('modal-bg').classList.add('open');
|
||||
}
|
||||
function closeModal(){document.getElementById('modal-bg').classList.remove('open');}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&modalOpen())closeModal();});
|
||||
|
||||
function fmtTs(ts){
|
||||
const d=new Date(ts),p=n=>String(n).padStart(2,'0');
|
||||
return (d.getMonth()+1)+'/'+d.getDate()+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
|
||||
}
|
||||
|
||||
async function openHistory(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
await flushNow(); // 지금 편집 중인 내용까지 서버에 반영한 뒤 이력을 본다
|
||||
openModal('🕘 변경 이력 — '+proj.name,'<div class="rb-empty">불러오는 중…</div>');
|
||||
try{
|
||||
const [hr,tr]=await Promise.all([
|
||||
fetch('/api/workshop/history/'+encodeURIComponent(proj.id),{headers:authH()}),
|
||||
fetch('/api/workshop/trash',{headers:authH()}),
|
||||
]);
|
||||
const h=(await hr.json()).history||[];
|
||||
const t=(await tr.json()).deleted||[];
|
||||
if(!modalOpen())return;
|
||||
let html='<h4>이 프로젝트 — 변경 "직전" 상태로 되돌리기</h4>';
|
||||
html+=h.length?h.map(e=>'<div class="rb-hist-row"><span class="when">'+fmtTs(e.ts)+'</span>'
|
||||
+'<span class="sum">부품 '+e.parts+' · 작업 '+e.tasksDone+'/'+e.tasks+' · 메모 '+e.notesLen+'자 · 개요 '+e.descLen+'자</span>'
|
||||
+'<button onclick="restoreHistory('+Number(e.ts)+')">되돌리기</button></div>').join('')
|
||||
:'<div class="rb-empty">아직 변경 이력이 없습니다. 수정하면 그 직전 상태가 자동으로 남습니다(최대 30개, 연속 편집은 45초 단위로 합쳐짐).</div>';
|
||||
html+='<h4>삭제된 프로젝트 복구</h4>';
|
||||
html+=t.length?t.map(e=>'<div class="rb-hist-row"><span class="when">'+fmtTs(e.ts)+'</span>'
|
||||
+'<span class="sum">'+escHtml(e.name)+' (첨부파일은 복구 안 됨)</span>'
|
||||
+'<button onclick="restoreTrash(\''+escJs(e.id)+'\')">복구</button></div>').join('')
|
||||
:'<div class="rb-empty">삭제된 프로젝트가 없습니다.</div>';
|
||||
document.getElementById('modal-body').innerHTML=html;
|
||||
}catch(e){
|
||||
if(modalOpen())document.getElementById('modal-body').innerHTML='<div class="rb-empty">이력을 불러오지 못했습니다: '+escHtml(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreHistory(ts){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
if(!confirm(fmtTs(ts)+' 변경 직전 상태로 "'+proj.name+'"을(를) 되돌릴까요?\n지금 상태도 이력에 남으므로 되돌린 뒤 다시 원래대로 복원할 수 있습니다.'))return;
|
||||
clearConflict();
|
||||
try{
|
||||
const r=await fetch('/api/workshop/history/'+encodeURIComponent(proj.id)+'/restore',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({ts})});
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
}catch(e){alert('되돌리기 실패: '+e.message);return;}
|
||||
closeModal();
|
||||
await softRefreshWorkshop();
|
||||
showSaveIndicator('되돌림');
|
||||
}
|
||||
|
||||
async function restoreTrash(id){
|
||||
try{
|
||||
const r=await fetch('/api/workshop/trash/'+encodeURIComponent(id)+'/restore',{method:'POST',headers:authH()});
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
}catch(e){alert('복구 실패: '+e.message);return;}
|
||||
closeModal();
|
||||
await softRefreshWorkshop();
|
||||
showSaveIndicator('복구됨');
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// 작업실 앱 — 파일 탭(2026-09-25 workshop-app.js를 기능별로 분리, 내용 변경 없음).
|
||||
// 파일 목록·업로드·STL 썸네일·CAD 편집기/슬라이서 연동.
|
||||
// classic script — 전역 함수/변수를 공유한다. HTML에서 core → parts → tasks → files → chat → init 순서로 로드(원래 한 파일의 순서 그대로).
|
||||
// ── 파일 ──────────────────────────────────────────────────────────────────────
|
||||
// 부품/작업/메모와 달리 workshopData(JSON)에 안 담고, 서버 디스크에 실제 파일로
|
||||
// 저장된다(case-storage.ts, doctor/lawyer 앱과 동일 패턴) — /api/workshop/files 로
|
||||
// 목록을 받아 그때그때 다시 그린다.
|
||||
const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'};
|
||||
let filesState={files:[],folders:[]};
|
||||
let stlSelection=new Set(); // 슬라이서로 보낼 STL relPath 다중선택(09-22, K2 슬라이서 연동)
|
||||
// 파일 목록 요청 일련번호 — 빠른 프로젝트 전환 시 오래된 응답이 나중에 도착해
|
||||
// 이전 프로젝트 파일 목록을 새 판에 그리는 레이스를 막는다(응답 도착 시점 검사).
|
||||
let filesLoadSeq=0;
|
||||
|
||||
async function loadFiles(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const seq=++filesLoadSeq;
|
||||
const container=document.getElementById('files-list-container');
|
||||
try{
|
||||
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(!r.ok)throw new Error('불러오기 실패');
|
||||
const d=await r.json();
|
||||
if(seq!==filesLoadSeq)return; // 도착 사이에 더 새로운 요청이 갔다 — 옛 프로젝트 데이터라 폐기
|
||||
filesState.files=Array.isArray(d.files)?d.files:[];
|
||||
filesState.folders=Array.isArray(d.folders)?d.folders:[];
|
||||
}catch{
|
||||
if(seq===filesLoadSeq)container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
|
||||
return;
|
||||
}
|
||||
// 삭제된 파일이 남아있지 않게 선택 상태 정리.
|
||||
const liveRelPaths=new Set(filesState.files.map(f=>f.relPath));
|
||||
for(const rp of [...stlSelection])if(!liveRelPaths.has(rp))stlSelection.delete(rp);
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
// 프로젝트 파일 폴더 규칙(서버 workshop-layout.ts와 같은 이름): 비어 있어도 업로드 대상으로 고를 수 있게 권장 폴더를 항상 보여준다.
|
||||
const STD_FOLDERS=['CAD','CAD/참고','출력','사진','영상'];
|
||||
function folderRank(f){const i=STD_FOLDERS.indexOf(f);return i>=0?i:(f.startsWith('CAD/')?1.5:100);}
|
||||
function renderFolderSelect(){
|
||||
const sel=document.getElementById('upload-folder-select');
|
||||
const prev=sel.value;
|
||||
const all=[...new Set([...STD_FOLDERS,...filesState.folders])].filter(f=>f!=='조립설명서').sort((a,b)=>folderRank(a)-folderRank(b)||a.localeCompare(b));
|
||||
sel.innerHTML='<option value="">(폴더 없음)</option>'+all.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}${STD_FOLDERS.includes(f)&&!filesState.folders.includes(f)?' (권장)':''}</option>`).join('');
|
||||
if([...sel.options].some(o=>o.value===prev))sel.value=prev;
|
||||
}
|
||||
|
||||
function renderFiles(){
|
||||
renderFolderSelect();
|
||||
const container=document.getElementById('files-list-container');
|
||||
if(!filesState.files.length&&!filesState.folders.length){
|
||||
container.innerHTML='<div class="rb-files-empty">아직 첨부된 파일이 없습니다. 사진/CAD/STL 등을 업로드해 보세요.</div>';
|
||||
return;
|
||||
}
|
||||
// 폴더별로 묶기 — 최상위(폴더 없음) 파일은 "미분류"로.
|
||||
const groups=new Map();
|
||||
for(const f of filesState.folders)groups.set(f,[]);
|
||||
for(const file of filesState.files){
|
||||
const key=file.folder||'';
|
||||
if(!groups.has(key))groups.set(key,[]);
|
||||
groups.get(key).push(file);
|
||||
}
|
||||
const keys=[...groups.keys()].sort((a,b)=>{
|
||||
if(a==='')return 1; if(b==='')return -1;
|
||||
return folderRank(a)-folderRank(b)||a.localeCompare(b); // 규칙 폴더(CAD→참고→출력→사진→영상)를 먼저
|
||||
});
|
||||
container.innerHTML=keys.map(key=>{
|
||||
const files=groups.get(key)||[];
|
||||
const label=key||'미분류';
|
||||
const delBtn=key?`<button class="rb-files-group-del" onclick="deleteFolder('${escJs(key)}')">🗑 폴더 삭제</button>`:'';
|
||||
const grid=files.length?`<div class="rb-files-grid">${files.map(renderFileCard).join('')}</div>`
|
||||
:'<div class="rb-files-empty" style="padding:8px 0">(빈 폴더)</div>';
|
||||
return `<div class="rb-files-group">
|
||||
<div class="rb-files-group-hdr">
|
||||
<span class="rb-files-group-name">📁 ${escAttr(label)}</span>
|
||||
<span class="rb-files-group-count">${files.length}개</span>
|
||||
${delBtn}
|
||||
</div>
|
||||
${grid}
|
||||
</div>`;
|
||||
}).join('');
|
||||
const btn=document.getElementById('stl-slice-btn');
|
||||
const countEl=document.getElementById('stl-sel-count');
|
||||
if(countEl)countEl.textContent=String(stlSelection.size);
|
||||
if(btn)btn.disabled=stlSelection.size===0;
|
||||
}
|
||||
|
||||
const STL_RE=/\.stl$/i;
|
||||
function stlThumbFail(img){
|
||||
const sp=document.createElement('span');sp.className='rb-file-icon';sp.textContent='🔧';
|
||||
img.replaceWith(sp);
|
||||
}
|
||||
function renderFileCard(file){
|
||||
const isStl=STL_RE.test(file.name);
|
||||
const icon=isStl?'🔧':(FILE_ICONS[file.category]||FILE_ICONS.other);
|
||||
const proj=currentProject();
|
||||
// STL은 서버가 OpenSCAD로 렌더한 썸네일(캐시)을 쓴다 — ASCII STL/큰 파일/렌더 실패는 404라
|
||||
// onerror로 기존 🔧 아이콘으로 되돌아간다.
|
||||
const thumb=file.category==='photo'
|
||||
? `<img src="${escAttr(file.url)}" alt="${escAttr(file.name)}" loading="lazy">`
|
||||
: (isStl&&proj)
|
||||
? `<img class="stl-thumb" src="/api/workshop/thumb?projectId=${encodeURIComponent(proj.id)}&relPath=${encodeURIComponent(file.relPath)}" alt="" loading="lazy" onerror="stlThumbFail(this)">`
|
||||
: `<span class="rb-file-icon">${icon}</span>`;
|
||||
// STL은 새탭 다운로드 대신 Three.js CAD 편집기 팝업으로 연다(작업실↔K2 CAD 플러그인 연동).
|
||||
const openAttrs=isStl?`href="javascript:void(0)" onclick="openCadEditorForFile('${escJs(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`;
|
||||
// STL만 슬라이서로 보낼 다중선택 체크박스(09-22) — 여러 부품 STL을 한 판에 얹어 같이 슬라이싱.
|
||||
const checked=stlSelection.has(file.relPath);
|
||||
const selBox=isStl?`<input type="checkbox" class="rb-file-stl-sel" title="슬라이서로 보내기 선택" ${checked?'checked':''} onclick="event.stopPropagation()" onchange="toggleStlSelect('${escJs(file.relPath)}',this.checked)">`:'';
|
||||
return `<div class="rb-file-card${checked?' stl-selected':''}" data-rel="${escAttr(file.relPath)}">
|
||||
${selBox}
|
||||
<button class="rb-file-del" onclick="deleteFile('${escJs(file.relPath)}')" title="삭제">✕</button>
|
||||
<a ${openAttrs}>
|
||||
<div class="rb-file-thumb">${thumb}</div>
|
||||
<div class="rb-file-info">
|
||||
<div class="rb-file-name" title="${escAttr(file.name)}">${escAttr(file.name)}</div>
|
||||
<div class="rb-file-size">${fmtFileSize(file.size)}${isStl?' · CAD 편집기에서 열기':''}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 작업실 프로젝트 relPath → 워크스페이스 전체 기준 상대경로. K2 CAD/슬라이서 라우트
|
||||
// (/api/cad/file, /api/cad/list 등)는 findStlFiles()로 워크스페이스 전체를 훑으므로
|
||||
// 이 접두어(workshop/<projectId>/)만 붙이면 작업실 파일도 그대로 잡힌다.
|
||||
function workshopFilePath(relPath){
|
||||
const proj=currentProject();
|
||||
return proj?('workshop/'+proj.id+'/'+relPath):null;
|
||||
}
|
||||
|
||||
// K2 프린터 앱의 "🔧 STL 편집기"(Three.js, cad-editor-app.html)를 그대로 재사용 — ?path= 로
|
||||
// 어느 파일을 열지 지정하면 편집기가 자동으로 로드한다(cad-editor-view.js 09-22 추가).
|
||||
function openCadEditorForFile(relPath){
|
||||
const workspacePath=workshopFilePath(relPath);
|
||||
if(!workspacePath)return;
|
||||
const w=1320,h=860;
|
||||
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
|
||||
window.open('/html/cad-editor-app.html?path='+encodeURIComponent(workspacePath),'cad-editor',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
|
||||
}
|
||||
|
||||
// ── 슬라이서 연동(09-22) ────────────────────────────────────────────────────
|
||||
// STL 카드 체크박스로 여러 부품(예: 스캐너 rig의 base_plate+head_plate+turntable 등)을
|
||||
// 골라 K2 슬라이서 팝업(slicer-app.html)에 한 번에 올린다. 실제 로드는 slicer-view.js의
|
||||
// ?load=<워크스페이스 상대경로,...> 지원(09-22 추가)이 처리.
|
||||
function toggleStlSelect(relPath,checked){
|
||||
if(checked)stlSelection.add(relPath);else stlSelection.delete(relPath);
|
||||
const card=document.querySelector(`.rb-file-card[data-rel="${CSS.escape(relPath)}"]`);
|
||||
if(card)card.classList.toggle('stl-selected',checked);
|
||||
const btn=document.getElementById('stl-slice-btn');
|
||||
const countEl=document.getElementById('stl-sel-count');
|
||||
if(countEl)countEl.textContent=String(stlSelection.size);
|
||||
if(btn)btn.disabled=stlSelection.size===0;
|
||||
}
|
||||
|
||||
function openSlicerForSelected(){
|
||||
if(!stlSelection.size)return;
|
||||
const paths=[...stlSelection].map(workshopFilePath).filter(Boolean);
|
||||
if(!paths.length)return;
|
||||
const w=1320,h=860;
|
||||
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
|
||||
// 쉼표가 파일명에 있으면 join(',') 구분자와 겹쳐 슬라이서 쪽 split(',')에서 깨지므로
|
||||
// JSON 배열로 인코딩한다(슬라이서 쪽은 JSON 우선, 옛 쉼표 형식도 폴백 해석).
|
||||
window.open('/html/slicer-app.html?load='+encodeURIComponent(JSON.stringify(paths)),'k2-slicer',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
|
||||
}
|
||||
|
||||
function fmtFileSize(n){
|
||||
if(!n&&n!==0)return '';
|
||||
if(n<1024)return n+'B';
|
||||
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
|
||||
return (n/1024/1024).toFixed(1)+'MB';
|
||||
}
|
||||
|
||||
async function promptNewFolder(){
|
||||
const name=(prompt('새 폴더 이름을 입력하세요 (예: 사진, CAD, STL):')||'').trim();
|
||||
if(!name)return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
try{
|
||||
const r=await fetch('/api/workshop/files/folder',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,folder:name})});
|
||||
if(!r.ok)throw new Error();
|
||||
await loadFiles();
|
||||
}catch{alert('폴더 생성 실패');}
|
||||
}
|
||||
|
||||
async function deleteFolder(folder){
|
||||
if(!confirm(`"${folder}" 폴더와 그 안의 파일이 모두 삭제됩니다. 계속할까요?`))return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
try{
|
||||
await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath:folder})});
|
||||
}catch{}
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
async function deleteFile(relPath){
|
||||
if(!confirm('이 파일을 삭제할까요?'))return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
try{
|
||||
await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath})});
|
||||
}catch{}
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList){
|
||||
const proj=currentProject();
|
||||
if(!proj||!fileList||!fileList.length)return;
|
||||
const folder=document.getElementById('upload-folder-select').value;
|
||||
const zone=document.getElementById('files-dropzone');
|
||||
const origText=zone.textContent;
|
||||
for(const file of fileList){
|
||||
zone.textContent=`업로드 중… ${file.name}`;
|
||||
const fd=new FormData();
|
||||
fd.append('file',file);
|
||||
try{
|
||||
const qs='projectId='+encodeURIComponent(proj.id)+(folder?'&folder='+encodeURIComponent(folder):'');
|
||||
const r=await fetch('/api/workshop/upload?'+qs,{method:'POST',headers:authH(),body:fd});
|
||||
if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.error||'업로드 실패');}
|
||||
}catch(e){
|
||||
alert(`"${file.name}" 업로드 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
zone.textContent=origText;
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
function handleFileInputChange(ev){
|
||||
uploadFiles(ev.target.files);
|
||||
ev.target.value='';
|
||||
}
|
||||
|
||||
(function initDropzone(){
|
||||
const zone=document.getElementById('files-dropzone');
|
||||
['dragenter','dragover'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.add('dragover');}));
|
||||
['dragleave','drop'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.remove('dragover');}));
|
||||
zone.addEventListener('drop',e=>{
|
||||
const files=e.dataTransfer?.files;
|
||||
if(files&&files.length)uploadFiles(files);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
// 작업실 앱 — 시작(인증 확인 → 데이터/채팅 로드 → 폴링). 2026-09-25 분리. 다른 모듈이 전부 로드된 뒤 마지막에 실행돼야 한다.
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
(async function init(){
|
||||
if(!(await checkAuth()))return;
|
||||
{const tb=document.getElementById('theme-btn');if(tb)tb.textContent=document.documentElement.getAttribute('data-theme')==='light'?'☀️':'🌙';}
|
||||
await loadWorkshopData();
|
||||
await loadHistory();
|
||||
loadModelPill();
|
||||
// 다른 탭/채팅 경로의 변경을 15초마다(보이는 탭에서만) 반영 — 편집 중이면 미룬다.
|
||||
setInterval(()=>{if(!document.hidden)softRefreshWorkshop({poll:true});},15000);
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)softRefreshWorkshop({poll:true});});
|
||||
addMsg('system',chatGreeting());
|
||||
document.getElementById('chat-input').focus();
|
||||
})();
|
||||
@@ -0,0 +1,322 @@
|
||||
// 작업실 앱 — 부품 탭(2026-09-25 workshop-app.js를 기능별로 분리, 내용 변경 없음).
|
||||
// 부품 카드/표·필터·정렬, 예산 게이지, 시세 조회, BOM 내보내기.
|
||||
// classic script — 전역 함수/변수를 공유한다. HTML에서 core → parts → tasks → files → chat → init 순서로 로드(원래 한 파일의 순서 그대로).
|
||||
// ── 부품 ──────────────────────────────────────────────────────────────────────
|
||||
function fmtWon(n){return (n||0).toLocaleString('ko-KR')+'원';}
|
||||
|
||||
// 펼침 상태는 화면 전용(서버에 저장 안 함) — 카드 다시 그려도 유지되게 모듈 전역에 둔다.
|
||||
const expandedParts=new Set();
|
||||
function togglePartExpand(id){
|
||||
if(expandedParts.has(id))expandedParts.delete(id);else expandedParts.add(id);
|
||||
renderParts();
|
||||
}
|
||||
|
||||
// ── 보기 상태(화면 전용, 저장 안 함) — 카드/표, 상태 필터, 정렬 ──
|
||||
let partsView='card';
|
||||
try{partsView=localStorage.getItem('ws_parts_view')==='table'?'table':'card';}catch{}
|
||||
let partsFilter='all';
|
||||
let partsSort='order';
|
||||
const STATUS_ORDER={'검토중':0,'주문완료':1,'보유':2};
|
||||
function setPartsView(v){partsView=v;try{localStorage.setItem('ws_parts_view',v);}catch{}renderParts();}
|
||||
function setPartsFilter(f){partsFilter=f;renderParts();}
|
||||
function setPartsSort(v){partsSort=v;renderParts();}
|
||||
|
||||
function visibleParts(){
|
||||
let list=parts.filter(p=>partsFilter==='all'||p.status===partsFilter);
|
||||
if(partsSort==='name')list=[...list].sort((a,b)=>String(a.name).localeCompare(String(b.name),'ko'));
|
||||
else if(partsSort==='amount')list=[...list].sort((a,b)=>(b.qty*b.unitPrice)-(a.qty*a.unitPrice));
|
||||
else if(partsSort==='status')list=[...list].sort((a,b)=>(STATUS_ORDER[a.status]??9)-(STATUS_ORDER[b.status]??9));
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderPartsBar(){
|
||||
const cnt=st=>parts.filter(p=>st==='all'||p.status===st).length;
|
||||
document.getElementById('parts-chips').innerHTML=['all','검토중','주문완료','보유'].map(st=>
|
||||
`<button class="rb-chip${partsFilter===st?' active':''}" onclick="setPartsFilter('${st}')">${st==='all'?'전체':st} ${cnt(st)}</button>`).join(' ');
|
||||
document.querySelectorAll('#parts-view-seg button').forEach(b=>b.classList.toggle('active',b.dataset.view===partsView));
|
||||
const so=document.getElementById('parts-sort');
|
||||
if(so&&so.value!==partsSort)so.value=partsSort;
|
||||
}
|
||||
|
||||
// 시세 조회 결과 한 줄(카드용) — 단가 대비 최저가 변동을 색으로 보여준다.
|
||||
function priceLineHtml(p){
|
||||
const pc=p.priceCheck;
|
||||
if(!pc)return '';
|
||||
const d=new Date(pc.at);
|
||||
let delta='';
|
||||
if(p.unitPrice>0){
|
||||
const diff=pc.min-p.unitPrice;
|
||||
delta=diff<0?` · <span class="down">▼ 단가보다 ${fmtWon(-diff)} 저렴</span>`:diff>0?` · <span class="up">▲ 단가보다 ${fmtWon(diff)} 비쌈</span>`:' · 단가와 동일';
|
||||
}
|
||||
return `<div class="rb-price-line">시세(다나와 ${d.getMonth()+1}/${d.getDate()}, ${pc.count}건): 최저 <b>${fmtWon(pc.min)}</b> · 중앙값 ${fmtWon(pc.median)}${delta}</div>`;
|
||||
}
|
||||
|
||||
function renderPartCard(p){
|
||||
const expanded=expandedParts.has(p.id);
|
||||
return `
|
||||
<div class="rb-part-card${expanded?' expanded':''}" data-id="${escAttr(p.id)}">
|
||||
<div class="rb-part-hdr" onclick="togglePartExpand('${p.id}')">
|
||||
<span class="rb-part-chevron">▶</span>
|
||||
<span class="rb-part-name">${escAttr(p.name)||'(이름 없음)'}</span>
|
||||
<span class="rb-part-badge st-${escAttr(p.status)}">${escAttr(p.status)}</span>
|
||||
<span class="rb-part-sub">${fmtWon(p.qty*p.unitPrice)}</span>
|
||||
</div>
|
||||
<div class="rb-part-body" onclick="event.stopPropagation()">
|
||||
<div class="rb-part-row">
|
||||
<div class="rb-part-field grow"><label>이름</label><input type="text" value="${escAttr(p.name)}" oninput="updatePart('${p.id}','name',this.value)"></div>
|
||||
<div class="rb-part-field qty"><label>수량</label><input type="number" min="0" value="${escAttr(p.qty)}" oninput="updatePart('${p.id}','qty',this.value)"></div>
|
||||
<div class="rb-part-field price"><label>단가</label><input type="number" min="0" value="${escAttr(p.unitPrice)}" oninput="updatePart('${p.id}','unitPrice',this.value)"></div>
|
||||
<div class="rb-part-field status"><label>상태</label>
|
||||
<select onchange="updatePart('${p.id}','status',this.value)">
|
||||
<option value="검토중" ${p.status==='검토중'?'selected':''}>검토중</option>
|
||||
<option value="주문완료" ${p.status==='주문완료'?'selected':''}>주문완료</option>
|
||||
<option value="보유" ${p.status==='보유'?'selected':''}>보유</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rb-part-row">
|
||||
<div class="rb-part-field grow"><label>메모</label><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></div>
|
||||
</div>
|
||||
<div class="rb-part-links">
|
||||
<label>정보(구매처/대체품/업그레이드/레포 등)</label>
|
||||
${(p.links||[]).map(link=>`
|
||||
<div class="rb-link-row" data-link-id="${escAttr(link.id)}">
|
||||
<input type="text" class="rb-link-label" placeholder="라벨(예: 쿠팡)" value="${escAttr(link.label)}" oninput="updateLink('${p.id}','${link.id}','label',this.value)">
|
||||
<input type="text" placeholder="URL" value="${escAttr(link.url)}" oninput="updateLink('${p.id}','${link.id}','url',this.value)">
|
||||
${(safeUrl(link.url)?`<a class="rb-link-open" href="${escAttr(safeUrl(link.url))}" target="_blank" rel="noopener">↗</a>`:'')}
|
||||
<button class="rb-del-btn" onclick="deleteLink('${p.id}','${link.id}')">✕</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
<button class="rb-link-add" onclick="addLink('${p.id}')">+ 링크 추가</button>
|
||||
</div>
|
||||
${priceLineHtml(p)}
|
||||
<div class="rb-part-actions"><button class="rb-mini-btn" onclick="checkPartPrice('${p.id}')" style="margin-right:auto">💰 시세 조회</button><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPartsTable(list){
|
||||
const total=list.reduce((sum,p)=>sum+p.qty*p.unitPrice,0);
|
||||
const rows=list.map(p=>`
|
||||
<tr data-id="${escAttr(p.id)}">
|
||||
<td><input type="text" value="${escAttr(p.name)}" placeholder="(이름 없음)" oninput="updatePart('${p.id}','name',this.value)"></td>
|
||||
<td style="width:64px"><input type="number" min="0" value="${escAttr(p.qty)}" oninput="updatePart('${p.id}','qty',this.value)"></td>
|
||||
<td style="width:104px"><input type="number" min="0" value="${escAttr(p.unitPrice)}" oninput="updatePart('${p.id}','unitPrice',this.value)"></td>
|
||||
<td class="sub rb-pt-sub">${fmtWon(p.qty*p.unitPrice)}</td>
|
||||
<td style="width:92px"><select onchange="updatePart('${p.id}','status',this.value)">
|
||||
${['검토중','주문완료','보유'].map(st=>`<option value="${st}" ${p.status===st?'selected':''}>${st}</option>`).join('')}
|
||||
</select></td>
|
||||
<td><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></td>
|
||||
<td style="white-space:nowrap"><button class="rb-mini-btn" title="시세 조회" onclick="checkPartPrice('${p.id}')">💰</button> <button class="rb-del-btn" onclick="deletePart('${p.id}')">✕</button></td>
|
||||
</tr>`).join('');
|
||||
return `<table class="rb-ptable"><thead><tr>
|
||||
<th class="sortable" onclick="setPartsSort('name')">이름</th><th>수량</th><th>단가</th>
|
||||
<th class="sortable" onclick="setPartsSort('amount')">소계</th><th class="sortable" onclick="setPartsSort('status')">상태</th><th>메모</th><th></th>
|
||||
</tr></thead><tbody>${rows}</tbody>
|
||||
<tfoot><tr><td colspan="3" id="pt-foot-count">표시 중 ${list.length}개</td><td class="sub" id="pt-foot-sum">${fmtWon(total)}</td><td colspan="3"></td></tr></tfoot></table>`;
|
||||
}
|
||||
|
||||
function updateTableFooter(){
|
||||
const list=visibleParts();
|
||||
const el=document.getElementById('pt-foot-sum');
|
||||
if(el)el.textContent=fmtWon(list.reduce((sum,p)=>sum+p.qty*p.unitPrice,0));
|
||||
}
|
||||
|
||||
function renderParts(){
|
||||
renderPartsBar();
|
||||
const container=document.getElementById('parts-container');
|
||||
if(!parts.length){container.innerHTML='<div class="rb-empty">아직 등록된 부품이 없습니다. "+ 부품 추가"를 누르거나 채팅으로 등록해 보세요.</div>';return;}
|
||||
const list=visibleParts();
|
||||
if(!list.length){container.innerHTML='<div class="rb-empty">이 상태의 부품이 없습니다.</div>';return;}
|
||||
container.innerHTML=partsView==='table'?renderPartsTable(list):list.map(renderPartCard).join('');
|
||||
}
|
||||
|
||||
function updatePart(id,field,value){
|
||||
const p=parts.find(x=>x.id===id);
|
||||
if(!p)return;
|
||||
if(field==='qty'||field==='unitPrice')p[field]=Math.max(0,Number(value)||0);
|
||||
else p[field]=value;
|
||||
const row=document.querySelector(`.rb-ptable tr[data-id="${id}"]`);
|
||||
if(row){
|
||||
if(field==='qty'||field==='unitPrice')row.querySelector('.rb-pt-sub').textContent=fmtWon(p.qty*p.unitPrice);
|
||||
updateTableFooter();
|
||||
}
|
||||
if(field==='status')renderPartsBar(); // 필터 칩의 개수 갱신
|
||||
const card=document.querySelector(`.rb-part-card[data-id="${id}"]`);
|
||||
if(card){
|
||||
if(field==='qty'||field==='unitPrice')card.querySelector('.rb-part-sub').textContent=fmtWon(p.qty*p.unitPrice);
|
||||
if(field==='name')card.querySelector('.rb-part-name').textContent=p.name||'(이름 없음)';
|
||||
if(field==='status'){
|
||||
const badge=card.querySelector('.rb-part-badge');
|
||||
badge.className='rb-part-badge st-'+p.status;
|
||||
badge.textContent=p.status;
|
||||
}
|
||||
}
|
||||
updateBudget();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function addPart(){
|
||||
const id=genId('p');
|
||||
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:'',links:[]});
|
||||
expandedParts.add(id);
|
||||
partsFilter='all'; // 새 부품(검토중)이 필터에 가려지지 않게
|
||||
renderParts();
|
||||
if(partsView==='table')document.querySelector(`.rb-ptable tr[data-id="${id}"] input[type=text]`)?.focus();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function deletePart(id){
|
||||
parts=parts.filter(x=>x.id!==id);
|
||||
expandedParts.delete(id);
|
||||
renderParts();
|
||||
updateBudget();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function addLink(partId){
|
||||
const p=parts.find(x=>x.id===partId);
|
||||
if(!p)return;
|
||||
if(!p.links)p.links=[];
|
||||
p.links.push({id:genId('lk'),label:'',url:''});
|
||||
renderParts();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function updateLink(partId,linkId,field,value){
|
||||
const p=parts.find(x=>x.id===partId);
|
||||
if(!p||!p.links)return;
|
||||
const link=p.links.find(x=>x.id===linkId);
|
||||
if(!link)return;
|
||||
link[field]=value;
|
||||
// url이 생기거나 없어지면 ↗ 열기 버튼만 DOM에서 직접 갱신 — 전체를 다시 그리면
|
||||
// input이 리렌더되면서 타이핑 중 커서/포커스가 끊긴다.
|
||||
if(field==='url'){
|
||||
const row=document.querySelector(`.rb-part-card[data-id="${partId}"] .rb-link-row[data-link-id="${linkId}"]`);
|
||||
if(row){
|
||||
let a=row.querySelector('.rb-link-open');
|
||||
if(value){
|
||||
if(!a){
|
||||
a=document.createElement('a');
|
||||
a.className='rb-link-open'; a.target='_blank'; a.rel='noopener'; a.textContent='↗';
|
||||
row.querySelector('.rb-del-btn').before(a);
|
||||
}
|
||||
const su=safeUrl(value);
|
||||
if(su)a.href=su;else a.removeAttribute('href'); // javascript: 등은 링크로 만들지 않는다
|
||||
}else if(a){
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// ── 시세 조회(다나와) ─────────────────────────────────────────────────────────
|
||||
// PC 부품 위주라 전자부품/소모품은 결과가 없을 수 있다 — 그땐 채팅으로 웹 검색을 권한다.
|
||||
async function checkPartPrice(id){
|
||||
const p=parts.find(x=>x.id===id);
|
||||
if(!p)return;
|
||||
if(!String(p.name).trim()){alert('부품 이름을 먼저 입력하세요.');return;}
|
||||
openModal('💰 시세 조회 — '+p.name,'<div class="rb-empty">다나와 조회 중… (최대 10~20초)</div>');
|
||||
try{
|
||||
const r=await fetch('/api/workshop/part-price',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({name:p.name})});
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
const d=await r.json();
|
||||
if(!modalOpen())return; // 그 사이 닫힘
|
||||
if(!d.count){
|
||||
document.getElementById('modal-body').innerHTML='<div class="rb-empty">다나와에 결과가 없습니다. 다나와는 PC 부품 위주라 전자부품/소모품은 안 나올 수 있어요.<br>채팅에 "이 부품 최저가 찾아줘"라고 하면 웹 검색으로 찾아줍니다.</div>';
|
||||
return;
|
||||
}
|
||||
const min=Number(d.min),med=Number(d.median),cnt=Number(d.count);
|
||||
document.getElementById('modal-body').innerHTML=
|
||||
'<div style="margin-bottom:8px">"'+escHtml(d.query)+'" 다나와 <b>'+cnt+'건</b> — 최저 <b>'+fmtWon(min)+'</b> · 중앙값 '+fmtWon(med)+'</div>'
|
||||
+d.items.map(i=>'<div class="rb-price-item"><span>'+escHtml(i.name)+'</span><span>'+fmtWon(Number(i.price))+'</span></div>').join('')
|
||||
+'<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">'
|
||||
+'<button class="rb-add-btn" style="margin:0" onclick="applyPartPrice(\''+id+'\','+min+','+med+','+cnt+',true)">최저가 '+fmtWon(min)+' 단가로 적용</button>'
|
||||
+'<button class="rb-mini-btn" onclick="applyPartPrice(\''+id+'\','+min+','+med+','+cnt+',false)">시세만 기록</button></div>';
|
||||
}catch(e){
|
||||
if(modalOpen())document.getElementById('modal-body').innerHTML='<div class="rb-empty">시세 조회 실패: '+escHtml(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
function applyPartPrice(id,min,median,count,setUnit){
|
||||
const p=parts.find(x=>x.id===id);
|
||||
if(!p)return;
|
||||
p.priceCheck={min,median,count,at:Date.now()};
|
||||
if(setUnit)p.unitPrice=min;
|
||||
renderParts();
|
||||
updateBudget();
|
||||
scheduleSave();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
// ── BOM 내보내기 ──────────────────────────────────────────────────────────────
|
||||
// CSV는 엑셀이 한글을 깨뜨리지 않게 BOM(\uFEFF)을 붙이고, 수식 주입(=,+,-,@로 시작하는 셀)을 막는다.
|
||||
function csvCell(v){
|
||||
let t=String(v??'');
|
||||
if(/^[=+\-@\t\r]/.test(t))t="'"+t;
|
||||
return '"'+t.replace(/"/g,'""')+'"';
|
||||
}
|
||||
function exportBom(kind){
|
||||
const proj=currentProject();
|
||||
if(!proj||!parts.length){alert('내보낼 부품이 없습니다.');return;}
|
||||
const total=parts.reduce((sum,p)=>sum+p.qty*p.unitPrice,0);
|
||||
let text,mime,ext;
|
||||
if(kind==='csv'){
|
||||
const lines=[['부품','수량','단가','소계','상태','메모'].map(csvCell).join(',')];
|
||||
for(const p of parts)lines.push([csvCell(p.name),p.qty,p.unitPrice,p.qty*p.unitPrice,csvCell(p.status),csvCell(p.memo)].join(','));
|
||||
lines.push([csvCell('합계'),'','',total,'',''].join(','));
|
||||
text='\uFEFF'+lines.join('\r\n');mime='text/csv;charset=utf-8';ext='csv';
|
||||
}else{
|
||||
const c=v=>String(v??'').replace(/\|/g,'\\|').replace(/\r?\n/g,' ');
|
||||
const lines=['# '+proj.name+' — BOM','','| 부품 | 수량 | 단가 | 소계 | 상태 | 메모 |','|---|--:|--:|--:|---|---|'];
|
||||
for(const p of parts)lines.push('| '+[c(p.name),p.qty,fmtWon(p.unitPrice),fmtWon(p.qty*p.unitPrice),c(p.status),c(p.memo)].join(' | ')+' |');
|
||||
lines.push('| **합계** | | | **'+fmtWon(total)+'** | | |');
|
||||
text=lines.join('\n')+'\n';mime='text/markdown;charset=utf-8';ext='md';
|
||||
}
|
||||
const a=document.createElement('a');
|
||||
a.href=URL.createObjectURL(new Blob([text],{type:mime}));
|
||||
a.download=(proj.name.replace(/[\\/:*?"<>|]/g,'_')||'project')+'_BOM_'+new Date().toISOString().slice(0,10)+'.'+ext;
|
||||
document.body.appendChild(a);a.click();a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
|
||||
}
|
||||
|
||||
function deleteLink(partId,linkId){
|
||||
const p=parts.find(x=>x.id===partId);
|
||||
if(!p||!p.links)return;
|
||||
p.links=p.links.filter(x=>x.id!==linkId);
|
||||
renderParts();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function syncBudgetInput(){
|
||||
const el=document.getElementById('budget-input');
|
||||
if(el&&document.activeElement!==el)el.value=budget?String(budget):'';
|
||||
}
|
||||
function onBudgetChange(){
|
||||
budget=Math.max(0,Math.round(Number(document.getElementById('budget-input').value)||0));
|
||||
updateBudget();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function updateBudget(){
|
||||
const sum=status=>parts.filter(p=>p.status===status).reduce((s,p)=>s+p.qty*p.unitPrice,0);
|
||||
const review=sum('검토중'),ordered=sum('주문완료'),owned=sum('보유');
|
||||
const total=review+ordered+owned;
|
||||
document.getElementById('sum-review').textContent=fmtWon(review);
|
||||
document.getElementById('sum-ordered').textContent=fmtWon(ordered);
|
||||
document.getElementById('sum-owned').textContent=fmtWon(owned);
|
||||
document.getElementById('sum-total').textContent=fmtWon(total);
|
||||
// 목표 예산 게이지 — 80%부터 주황, 초과하면 빨강.
|
||||
const g=document.getElementById('budget-gauge'),bar=document.getElementById('budget-gauge-bar'),tx=document.getElementById('budget-gauge-text');
|
||||
if(budget>0){
|
||||
const pct=total/budget*100;
|
||||
g.style.display='block';
|
||||
bar.style.width=Math.min(100,pct)+'%';
|
||||
g.className='rb-gauge'+(pct>100?' over':pct>=80?' warn':'');
|
||||
tx.className='rb-gauge-text'+(pct>100?' over':'');
|
||||
tx.textContent=Math.round(pct)+'% 사용 · '+(total<=budget?'잔액 '+fmtWon(budget-total):'초과 '+fmtWon(total-budget));
|
||||
}else{
|
||||
g.style.display='none';tx.textContent='';tx.className='rb-gauge-text';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 작업실 앱 — 작업·메모 탭(2026-09-25 workshop-app.js를 기능별로 분리, 내용 변경 없음).
|
||||
// 작업 단계/작업/기한/진행률, 메모·개요 입력.
|
||||
// classic script — 전역 함수/변수를 공유한다. HTML에서 core → parts → tasks → files → chat → init 순서로 로드(원래 한 파일의 순서 그대로).
|
||||
// ── 작업(단계) ────────────────────────────────────────────────────────────────
|
||||
function taskStats(list){
|
||||
const t=list.length,d=list.filter(x=>x.done).length;
|
||||
return{t,d,pct:t?Math.round(d/t*100):0};
|
||||
}
|
||||
// 기한 상태: 지났으면 overdue, 3일 이내면 soon(완료된 작업은 표시 안 함).
|
||||
function dueClass(due,done){
|
||||
if(!due||done)return '';
|
||||
const days=Math.floor((new Date(due+'T00:00:00')-new Date(new Date().toDateString()))/86400000);
|
||||
return days<0?'overdue':days<=3?'soon':'';
|
||||
}
|
||||
|
||||
function renderPhases(){
|
||||
const container=document.getElementById('phases-container');
|
||||
container.innerHTML=phases.map(ph=>{
|
||||
const st=taskStats(ph.tasks);
|
||||
return `
|
||||
<div class="rb-phase" data-id="${escAttr(ph.id)}">
|
||||
<div class="rb-phase-hdr">
|
||||
<input class="rb-phase-name" value="${escAttr(ph.name)}" oninput="updatePhaseName('${ph.id}',this.value)">
|
||||
<span class="rb-phase-count">${st.d}/${st.t}</span>
|
||||
<div class="rb-phase-prog rb-prog"><i style="width:${st.pct}%"></i></div>
|
||||
<button class="rb-del-btn" onclick="deletePhase('${ph.id}')">✕</button>
|
||||
</div>
|
||||
<div class="rb-phase-tasks">
|
||||
${ph.tasks.map(t=>`
|
||||
<div class="rb-task-row" data-id="${escAttr(t.id)}">
|
||||
<input type="checkbox" ${t.done?'checked':''} onchange="updateTask('${ph.id}','${t.id}','done',this.checked)">
|
||||
<input type="text" class="${t.done?'done':''}" value="${escAttr(t.text)}" oninput="updateTask('${ph.id}','${t.id}','text',this.value)">
|
||||
<input type="date" class="rb-due ${dueClass(t.due,t.done)}" value="${escAttr(t.due||'')}" title="기한" onchange="updateTask('${ph.id}','${t.id}','due',this.value)">
|
||||
<button class="rb-del-btn" onclick="deleteTask('${ph.id}','${t.id}')">✕</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
<button class="rb-task-add" onclick="addTask('${ph.id}')">+ 작업 추가</button>
|
||||
</div>
|
||||
</div>`;}).join('');
|
||||
updateProgressUI();
|
||||
}
|
||||
|
||||
// 전체 진행률(작업 탭 상단 + 탭 라벨)과 단계별 카운트/바, 기한 색을 다시 계산해 그린다.
|
||||
// 체크박스를 눌렀을 때 카드 전체를 다시 그리지 않고(포커스 유지) 이것만 갱신한다.
|
||||
function updateProgressUI(){
|
||||
const all=phases.flatMap(ph=>ph.tasks);
|
||||
const st=taskStats(all);
|
||||
const overdue=all.filter(t=>dueClass(t.due,t.done)==='overdue').length;
|
||||
const tabBtn=document.querySelector('.rb-tab-btn[data-tab="tasks"]');
|
||||
if(tabBtn)tabBtn.textContent=st.t?'작업 ('+st.d+'/'+st.t+')':'작업';
|
||||
const box=document.getElementById('tasks-progress');
|
||||
if(box){
|
||||
box.innerHTML=st.t
|
||||
?'<div class="rb-prog-label"><span>전체 진행</span><span>'+st.d+'/'+st.t+' ('+st.pct+'%)'+(overdue?' · <b style="color:#f87171">기한 지남 '+overdue+'건</b>':'')+'</span></div><div class="rb-prog"><i style="width:'+st.pct+'%"></i></div>'
|
||||
:'';
|
||||
}
|
||||
for(const ph of phases){
|
||||
const el=document.querySelector('.rb-phase[data-id="'+ph.id+'"]');
|
||||
if(!el)continue;
|
||||
const ps=taskStats(ph.tasks);
|
||||
const cnt=el.querySelector('.rb-phase-count');if(cnt)cnt.textContent=ps.d+'/'+ps.t;
|
||||
const bar=el.querySelector('.rb-phase-prog i');if(bar)bar.style.width=ps.pct+'%';
|
||||
for(const t of ph.tasks){
|
||||
const due=el.querySelector('.rb-task-row[data-id="'+t.id+'"] input.rb-due');
|
||||
if(due)due.className='rb-due '+dueClass(t.due,t.done);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePhaseName(phaseId,value){
|
||||
const ph=phases.find(x=>x.id===phaseId);
|
||||
if(ph){ph.name=value;scheduleSave();}
|
||||
}
|
||||
|
||||
function addPhase(){
|
||||
phases.push({id:genId('ph'),name:'새 단계',tasks:[]});
|
||||
renderPhases();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function deletePhase(phaseId){
|
||||
phases=phases.filter(x=>x.id!==phaseId);
|
||||
renderPhases();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function updateTask(phaseId,taskId,field,value){
|
||||
const ph=phases.find(x=>x.id===phaseId);
|
||||
if(!ph)return;
|
||||
const t=ph.tasks.find(x=>x.id===taskId);
|
||||
if(!t)return;
|
||||
if(field==='due'){if(value)t.due=value;else delete t.due;}
|
||||
else t[field]=value;
|
||||
if(field==='done'){
|
||||
const input=document.querySelector(`.rb-phase[data-id="${phaseId}"] .rb-task-row[data-id="${taskId}"] input[type=text]`);
|
||||
if(input)input.classList.toggle('done',value);
|
||||
}
|
||||
if(field==='done'||field==='due')updateProgressUI();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function addTask(phaseId){
|
||||
const ph=phases.find(x=>x.id===phaseId);
|
||||
if(!ph)return;
|
||||
ph.tasks.push({id:genId('t'),text:'',done:false});
|
||||
renderPhases();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function deleteTask(phaseId,taskId){
|
||||
const ph=phases.find(x=>x.id===phaseId);
|
||||
if(!ph)return;
|
||||
ph.tasks=ph.tasks.filter(x=>x.id!==taskId);
|
||||
renderPhases();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
|
||||
// ── 메모 ──────────────────────────────────────────────────────────────────────
|
||||
function onNotesChange(){
|
||||
notes=document.getElementById('notes-textarea').value;
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// ── 개요 ──────────────────────────────────────────────────────────────────────
|
||||
// 메모 탭과 같은 구조 — 프로젝트 서술형 설명(description). 채팅 도구 set_description
|
||||
// 으로도 쓸 수 있고, 채팅 응답 후 softRefreshWorkshop이 갱신해준다.
|
||||
function onOverviewChange(){
|
||||
overviewDesc=document.getElementById('overview-textarea').value;
|
||||
scheduleSave();
|
||||
}
|
||||
Reference in New Issue
Block a user