refactor: "로봇 프로젝트" 단일 대시보드를 여러 프로젝트를 오가는 "작업실"로 일반화

로봇 프로젝트 하나만 다루던 대시보드/도구를 로봇 외 다른 메이커 프로젝트
(3D프린팅, 밀링 등)도 같은 틀로 관리할 수 있게 재설계. 사용자가 이름을
"작업실"로 정함.

- workshop-storage.ts(구 robot-storage.ts): 단일 문서 대신
  {projects:[], activeProjectId} 구조. 기존 실사용 중이던
  robot-project.json이 있으면 빈 시드로 덮어쓰지 않고 그 실제 데이터를
  그대로 첫 프로젝트("로봇 프로젝트")로 마이그레이션
- routes-workshop.ts(구 routes-robot.ts): GET/PUT /api/workshop/data
- workshop-project.ts(구 robot-project.ts) 채팅도구: project_name 파라미터
  추가(생략시 활성 프로젝트 대상), list_projects/create_project/
  set_active_project 액션 신설
- workshop-app.html(구 robot-app.html): 헤더에 프로젝트 선택 드롭다운 +
  새 프로젝트/삭제 버튼 추가. 장비(K2/밀링) 탭은 프로젝트와 무관하게
  전역 유지
- 세션 접두사 rb_→ws_(tool-scope.ts의 isWorkshopAppSession), 도구명
  robot_project→workshop_project
- 마이그레이션+새 프로젝트 생성/전환/삭제+채팅 연동까지 브라우저로 실제
  확인함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 19:04:49 +09:00
co-authored by Claude Sonnet 5
parent 4911ab005a
commit 7ec72e776a
11 changed files with 504 additions and 329 deletions
+861
View File
@@ -0,0 +1,861 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🛠️ 작업실</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.rb-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.rb-hdr h1{font-size:13px;font-weight:700;margin:0;}
.rb-hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
.ws-project-select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;font-weight:700;color:var(--text);font-family:inherit;outline:none;max-width:220px;}
.ws-project-select:focus{border-color:#f59e0b;}
.rb-budget{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
.rb-budget-item{flex:1;min-width:120px;padding:8px 14px;border-right:1px solid var(--line);}
.rb-budget-item:last-child{border-right:none;background:rgba(245,158,11,.06);}
.rb-budget-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.rb-budget-val{font-size:15px;font-weight:700;margin-top:2px;}
.rb-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
.rb-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden;}
.rb-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;}
.rb-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:8px 14px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;margin-bottom:-1px;}
.rb-tab-btn.active{color:#f59e0b;border-bottom-color:#f59e0b;}
.rb-tab-btn:hover:not(.active){color:var(--text);}
.rb-body{flex:1;overflow-y:auto;padding:14px;min-height:0;}
.rb-pane{display:none;}
.rb-pane.active{display:block;}
/* ── 채팅 패널 ── */
.rb-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.rb-resize:hover,.rb-resize.dragging{background:#f59e0b;}
.rb-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.rb-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.rb-chat-label{font-size:10px;font-weight:700;color:#f59e0b;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.rb-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.rb-chat-clear:hover{color:#ef4444;}
.rb-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.rb-msgs::-webkit-scrollbar{width:3px;}
.rb-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.rb-msg{display:flex;flex-direction:column;}
.rb-msg.user{align-items:flex-end;}
.rb-msg.assistant{align-items:flex-start;}
.rb-msg.system{align-items:center;}
.rb-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
.rb-msg.user .rb-bubble{background:#3d2c05;color:#fde9b8;border-radius:10px 10px 3px 10px;}
.rb-msg.assistant .rb-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.rb-msg.system .rb-bubble{background:transparent;color:var(--muted);font-size:11px;}
.rb-bubble p{margin:3px 0;}
.rb-bubble h3{font-size:13px;margin:6px 0 3px;}
.rb-bubble ul,.rb-bubble ol{padding-left:16px;margin:3px 0;}
.rb-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.rb-bubble strong{font-weight:700;}
.rb-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.rb-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:rbDot 1.2s ease-in-out infinite;}
.rb-dot:nth-child(2){animation-delay:.2s;}.rb-dot:nth-child(3){animation-delay:.4s;}
@keyframes rbDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.rb-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.rb-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
.rb-textarea:focus{border-color:#f59e0b;}
.rb-send{background:#7c4a03;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.rb-send:hover:not(:disabled){background:#9a5c03;}
.rb-send:disabled{opacity:.5;cursor:not-allowed;}
@media (max-width:900px){
.rb-layout{flex-direction:column;}
.rb-resize{display:none;}
.rb-chat{width:auto;height:38vh;border-left:none;border-top:1px solid var(--line);}
}
/* ── 부품 카드 ── */
.rb-part-card{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;overflow:hidden;background:var(--panel);}
.rb-part-hdr{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;user-select:none;}
.rb-part-hdr:hover{background:rgba(245,158,11,.05);}
.rb-part-chevron{font-size:10px;color:var(--muted);transition:transform .15s;flex-shrink:0;}
.rb-part-card.expanded .rb-part-chevron{transform:rotate(90deg);}
.rb-part-name{flex:1;font-size:13px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.rb-part-badge{font-size:10px;padding:2px 8px;border-radius:10px;flex-shrink:0;font-weight:700;}
.rb-part-badge.st-검토중{background:rgba(107,114,128,.15);color:#9ca3af;}
.rb-part-badge.st-주문완료{background:rgba(59,130,246,.15);color:#60a5fa;}
.rb-part-badge.st-보유{background:rgba(34,197,94,.15);color:#4ade80;}
.rb-part-sub{font-size:12px;color:var(--muted);flex-shrink:0;min-width:80px;text-align:right;}
.rb-part-body{display:none;padding:4px 12px 12px;border-top:1px solid var(--line);}
.rb-part-card.expanded .rb-part-body{display:block;}
.rb-part-row{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;}
.rb-part-field{display:flex;flex-direction:column;gap:2px;}
.rb-part-field label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
.rb-part-field input[type=text],.rb-part-field input[type=number],.rb-part-field select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.rb-part-field input:focus,.rb-part-field select:focus{border-color:#f59e0b;}
.rb-part-field.grow{flex:1;min-width:120px;}
.rb-part-field.qty{width:64px;}
.rb-part-field.price{width:110px;}
.rb-part-field.status{width:100px;}
.rb-part-links{margin-top:10px;}
.rb-part-links label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
.rb-link-row{display:flex;gap:6px;margin-top:6px;align-items:center;}
.rb-link-row input[type=text]{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.rb-link-row input:focus{border-color:#f59e0b;}
.rb-link-row input.rb-link-label{flex:0 0 110px;}
.rb-link-open{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted);text-decoration:none;flex-shrink:0;}
.rb-link-open:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-link-add{margin-top:6px;background:none;border:1px dashed var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-link-add:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-part-actions{display:flex;justify-content:flex-end;margin-top:8px;}
.rb-del-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:11px;color:var(--muted);padding:4px 10px;}
.rb-del-btn:hover{color:#ef4444;border-color:#ef4444;}
.rb-add-btn{margin-top:10px;background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:6px;padding:5px 12px;font-size:11px;color:#f59e0b;cursor:pointer;font-family:inherit;font-weight:700;}
.rb-add-btn:hover{background:rgba(245,158,11,.25);}
/* ── 작업(단계) ── */
.rb-phase{border:1px solid var(--line);border-radius:8px;margin-bottom:12px;overflow:hidden;}
.rb-phase-hdr{display:flex;align-items:center;gap:6px;padding:8px 10px;background:var(--panel);}
.rb-phase-name{flex:1;background:transparent;border:none;font-size:13px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
.rb-phase-tasks{padding:6px 10px 10px;}
.rb-task-row{display:flex;align-items:center;gap:6px;padding:3px 0;}
.rb-task-row input[type=text]{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:4px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.rb-task-row input[type=text]:focus{border-color:#f59e0b;}
.rb-task-row input[type=text].done{text-decoration:line-through;color:var(--muted);}
.rb-task-add,.rb-phase-add{background:none;border:1px dashed var(--line);border-radius:6px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;margin-top:4px;}
.rb-task-add:hover,.rb-phase-add:hover{border-color:#f59e0b;color:#f59e0b;}
/* ── 장비 위젯 ── */
.rb-equip-grid{display:flex;gap:14px;flex-wrap:wrap;}
.rb-equip-card{flex:1;min-width:300px;max-width:480px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel);}
.rb-equip-hdr{display:flex;align-items:center;gap:6px;padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;}
.rb-equip-dot{width:7px;height:7px;border-radius:50%;background:#6b7280;flex-shrink:0;}
.rb-equip-dot.on{background:#22c55e;}
.rb-equip-dot.off{background:#ef4444;}
.rb-equip-refresh{margin-left:auto;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);}
.rb-equip-refresh:hover{color:#f59e0b;}
.rb-equip-frame-wrap{width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;}
.rb-equip-frame-wrap img{width:100%;height:100%;object-fit:contain;}
.rb-equip-frame-wrap .rb-equip-placeholder{color:#666;font-size:11px;}
.rb-equip-status{padding:8px 12px;font-size:11px;color:var(--muted);line-height:1.7;}
.rb-equip-status b{color:var(--text);}
/* ── 메모 ── */
.rb-notes{width:100%;height:calc(100dvh - 220px);box-sizing:border-box;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;}
.rb-notes:focus{border-color:#f59e0b;}
</style>
</head>
<body>
<div class="rb-hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>🛠️ 작업실</h1>
<select class="ws-project-select" id="project-select" onchange="switchProject(this.value)"></select>
<button class="hdr-btn" onclick="createProjectPrompt()">+ 새 프로젝트</button>
<button class="hdr-btn" onclick="deleteProjectPrompt()">🗑 삭제</button>
<span class="rb-hdr-sp"></span>
<span class="rb-save-indicator" id="save-indicator"></span>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</div>
<div class="rb-budget">
<div class="rb-budget-item"><div class="rb-budget-label">검토중</div><div class="rb-budget-val" id="sum-review">0원</div></div>
<div class="rb-budget-item"><div class="rb-budget-label">주문완료</div><div class="rb-budget-val" id="sum-ordered">0원</div></div>
<div class="rb-budget-item"><div class="rb-budget-label">보유</div><div class="rb-budget-val" id="sum-owned">0원</div></div>
<div class="rb-budget-item"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
</div>
<div class="rb-layout">
<div class="rb-main">
<div class="rb-tabs">
<button class="rb-tab-btn active" data-tab="parts" onclick="switchTab('parts')">부품</button>
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
<button class="rb-tab-btn" data-tab="notes" onclick="switchTab('notes')">메모</button>
</div>
<div class="rb-body">
<div class="rb-pane active" id="pane-parts">
<div id="parts-container"></div>
<button class="rb-add-btn" onclick="addPart()">+ 부품 추가</button>
</div>
<div class="rb-pane" id="pane-tasks">
<div id="phases-container"></div>
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
</div>
<div class="rb-pane" id="pane-equip">
<div class="rb-equip-grid">
<div class="rb-equip-card">
<div class="rb-equip-hdr">
<span class="rb-equip-dot" id="k2-dot"></span>
<span>🖨️ K2 콤보</span>
<button class="rb-equip-refresh" onclick="refreshK2Widget()">↺</button>
</div>
<div class="rb-equip-frame-wrap"><img id="k2-frame" alt="" style="display:none"><span class="rb-equip-placeholder" id="k2-placeholder">불러오는 중...</span></div>
<div class="rb-equip-status" id="k2-status-text">-</div>
</div>
<div class="rb-equip-card">
<div class="rb-equip-hdr">
<span class="rb-equip-dot" id="mill-dot"></span>
<span>⚙️ CNC 밀링(프린터실)</span>
<button class="rb-equip-refresh" onclick="refreshMillWidget()">↺</button>
</div>
<div class="rb-equip-frame-wrap"><img id="mill-frame" alt="" style="display:none" onerror="millFrameError()" onload="millFrameLoaded()"><span class="rb-equip-placeholder" id="mill-placeholder">불러오는 중...</span></div>
<div class="rb-equip-status">20초마다 자동 새로고침(스냅샷) — <a href="/html/milling-app.html" target="_blank" style="color:#f59e0b">밀링 앱에서 라이브로 보기 ↗</a></div>
</div>
</div>
</div>
<div class="rb-pane" id="pane-notes">
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
</div>
</div>
</div>
<div class="rb-resize" id="resize-handle"></div>
<div class="rb-chat" id="chat-panel">
<div class="rb-chat-hdr">
<span class="rb-chat-label" id="chat-label">🛠️ 작업실 채팅</span>
<span class="rb-hdr-sp"></span>
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div>
<div class="rb-msgs" id="chat-msgs"></div>
<div class="rb-input-bar">
<textarea class="rb-textarea" id="chat-input" rows="1" placeholder="부품/작업/예산에 대해 물어보세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="rb-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
</div>
<script>
// ── 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{}}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
// ── 상태 ──────────────────────────────────────────────────────────────────────
// "작업실"은 여러 프로젝트를 담는다(workshopData.projects) — 화면엔 그중 하나
// (activeProjectId)만 표시하고, parts/phases/notes는 그 프로젝트의 내용을 가리키는
// 지역 변수다(기존 로봇전용 시절 코드를 최대한 그대로 재사용하기 위한 구조 — 함수 대부분은
// parts/phases/notes를 그대로 조작하고, 저장 직전에만 현재 프로젝트 객체로 동기화한다).
let workshopData={projects:[],activeProjectId:''};
let parts=[];
let phases=[];
let notes='';
let saveTimer=null;
let saveDebounce=null;
function currentProject(){
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
}
function loadLocalFromProject(){
const proj=currentProject();
parts=proj?(proj.parts||[]):[];
phases=proj?(proj.phases||[]):[];
notes=proj?(proj.notes||''):'';
}
function syncLocalToProject(){
const proj=currentProject();
if(!proj)return;
proj.parts=parts;
proj.phases=phases;
proj.notes=notes;
}
function renderProjectSelector(){
const sel=document.getElementById('project-select');
sel.innerHTML=workshopData.projects.map(p=>`<option value="${escAttr(p.id)}" ${p.id===workshopData.activeProjectId?'selected':''}>${escAttr(p.name)}</option>`).join('');
const label=document.getElementById('chat-label');
const proj=currentProject();
if(label&&proj)label.textContent='🛠️ '+proj.name+' 채팅';
}
function refreshAllPanes(){
expandedParts.clear();
renderProjectSelector();
renderParts();
renderPhases();
document.getElementById('notes-textarea').value=notes;
updateBudget();
}
function switchProject(id){
syncLocalToProject();
workshopData.activeProjectId=id;
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
}
function createProjectPrompt(){
const name=(prompt('새 프로젝트 이름을 입력하세요:')||'').trim();
if(!name)return;
const id=genId('proj');
workshopData.projects.push({id,name,parts:[],phases:[],notes:''});
workshopData.activeProjectId=id;
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
}
function deleteProjectPrompt(){
const proj=currentProject();
if(!proj)return;
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 되돌릴 수 없습니다.`))return;
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
workshopData.activeProjectId=workshopData.projects[0].id;
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
}
async function loadWorkshopData(){
try{
const r=await fetch('/api/workshop/data',{headers:authH()});
if(r.ok){
const d=await r.json();
workshopData.projects=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:''}];
workshopData.activeProjectId=id;
}
loadLocalFromProject();
refreshAllPanes();
}
function scheduleSave(){
clearTimeout(saveDebounce);
saveDebounce=setTimeout(saveProject,600);
}
async function saveProject(){
syncLocalToProject();
try{
await fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId})});
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}
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');
if(tab==='equip')startEquipTimers();else stopEquipTimers();
}
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
// "장비" 탭을 보고 있을 때만 폴링한다 — 계속 켜두면 K2는 매번 짧은 WebRTC 연결을,
// 밀링은 go2rtc 프레임 요청을 반복하니 안 보는 동안은 자원을 안 쓰는 게 맞다.
const MILL_CHANNEL_ID=1000;
let equipTimer=null;
function startEquipTimers(){
if(equipTimer)return;
refreshK2Widget();
refreshMillWidget();
equipTimer=setInterval(()=>{refreshK2Widget();refreshMillWidget();},20000);
}
function stopEquipTimers(){
clearInterval(equipTimer);
equipTimer=null;
}
function refreshMillWidget(){
const img=document.getElementById('mill-frame');
img.src='/api/nvr/frame/'+MILL_CHANNEL_ID+'?t='+Date.now();
}
function millFrameLoaded(){
document.getElementById('mill-frame').style.display='block';
document.getElementById('mill-placeholder').style.display='none';
document.getElementById('mill-dot').className='rb-equip-dot on';
}
function millFrameError(){
document.getElementById('mill-frame').style.display='none';
document.getElementById('mill-placeholder').style.display='block';
document.getElementById('mill-placeholder').textContent='카메라를 불러올 수 없습니다';
document.getElementById('mill-dot').className='rb-equip-dot off';
}
// K2 카메라는 go2rtc 없이 프린터 자체 WebRTC(:8000)로만 나오므로 정지 프레임을 얻으려면
// 짧게 연결→ontrack에서 캔버스로 한 프레임 캡처→즉시 연결 종료한다(k2-app.html의 상시
// 라이브 연결과 달리 "장비" 탭을 볼 때만, 20초 간격으로만 짧게 붙었다 끊는 방식).
function grabK2Frame(){
return new Promise((resolve)=>{
let settled=false;
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
const video=document.createElement('video');
video.autoplay=true;video.muted=true;video.playsInline=true;
const finish=(dataUrl)=>{
if(settled)return;
settled=true;
try{pc.close();}catch{}
try{video.remove();}catch{}
resolve(dataUrl);
};
// 비디오 엘리먼트를 DOM에 붙이지 않으면 일부 브라우저에서 프레임 디코딩이 실제로
// 시작되지 않아(재생 안 함) videoWidth/Height가 계속 0으로 남고 캔버스가 새까맣게
// 찍힌다 — 화면 밖(offscreen)에 실제로 붙이고 명시적으로 play()까지 호출해야 한다.
video.style.cssText='position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;';
document.body.appendChild(video);
const captureWhenReady=(triesLeft)=>{
if(settled)return;
if(video.videoWidth>0&&video.videoHeight>0){
try{
const c=document.createElement('canvas');
c.width=video.videoWidth;c.height=video.videoHeight;
c.getContext('2d').drawImage(video,0,0,c.width,c.height);
finish(c.toDataURL('image/jpeg',0.7));
}catch{finish(null);}
return;
}
if(triesLeft<=0){finish(null);return;}
setTimeout(()=>captureWhenReady(triesLeft-1),200);
};
pc.ontrack=(ev)=>{
video.srcObject=ev.streams[0];
video.play().catch(()=>{});
video.onloadeddata=()=>captureWhenReady(15);
};
pc.addTransceiver('video',{direction:'sendrecv'});
let offerSent=false;
const sendOfferOnce=()=>{
if(offerSent||settled)return;
offerSent=true;
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
.then(r=>r.text()).then(txt=>{
const res=JSON.parse(atob(txt));
if(res.type==='answer'&&!settled)pc.setRemoteDescription(new RTCSessionDescription(res));
}).catch(()=>finish(null));
};
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>finish(null));
setTimeout(()=>finish(null),8000);
});
}
function fmtK2Duration(sec){
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
if(h>0)return h+'시간 '+m+'분';
return m+'분';
}
const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정지',complete:'완료',cancelled:'취소됨',error:'오류'};
async function refreshK2Widget(){
const img=document.getElementById('k2-frame');
const placeholder=document.getElementById('k2-placeholder');
grabK2Frame().then(dataUrl=>{
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';}
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';}
});
try{
const r=await fetch('/api/k2/status',{headers:authH()});
const d=await r.json();
const dot=document.getElementById('k2-dot');
const statusEl=document.getElementById('k2-status-text');
if(!d.online){
dot.className='rb-equip-dot off';
statusEl.innerHTML='<b>오프라인</b> — 프린터가 꺼져있거나 네트워크에 없음';
return;
}
dot.className='rb-equip-dot '+(d.state==='printing'?'on':'');
const stateLabel=K2_STATE_LABEL[d.state]||d.state;
let html='상태: <b>'+escAttr(stateLabel)+'</b>';
if(d.filename)html+=' · 파일: <b>'+escAttr(d.filename)+'</b>';
if(d.state==='printing'){
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
}
statusEl.innerHTML=html;
}catch{
document.getElementById('k2-status-text').textContent='상태 조회 실패';
}
}
// ── 부품 ──────────────────────────────────────────────────────────────────────
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();
}
function renderParts(){
const container=document.getElementById('parts-container');
container.innerHTML=parts.map(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-${p.status}">${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="${p.qty}" oninput="updatePart('${p.id}','qty',this.value)"></div>
<div class="rb-part-field price"><label>단가</label><input type="number" min="0" value="${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)">
${link.url?`<a class="rb-link-open" href="${escAttr(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>
<div class="rb-part-actions"><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
</div>
</div>
`;}).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 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);
renderParts();
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);
}
a.href=value;
}else if(a){
a.remove();
}
}
}
scheduleSave();
}
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 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('보유');
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(review+ordered+owned);
}
// ── 작업(단계) ────────────────────────────────────────────────────────────────
function renderPhases(){
const container=document.getElementById('phases-container');
container.innerHTML=phases.map(ph=>`
<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)">
<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)">
<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('');
}
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;
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);
}
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();
}
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
// 세션ID를 "ws_"로 시작하게 만들어서 tool-scope.ts가 이 세션에서는 키워드 없이도
// workshop_project 도구를 항상 열어주게 한다(doctor/lawyer 앱과 같은 패턴).
const APP_KEY='ws';
let _sessionId=null;
function getSessionId(){return _sessionId||'ws_main';}
async function initSessionId(){
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
_sessionId='ws_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
async function loadHistory(){
const sid=getSessionId();
if(!sid||sid.endsWith('_main'))return;
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)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 escHtml(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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;
div.appendChild(b);
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
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);
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
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});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
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);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='전송';}
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
await loadWorkshopData();
}
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','새 대화가 시작되었습니다.');
}
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{};});
})();
// ── Init ──────────────────────────────────────────────────────────────────────
(async function init(){
if(!(await checkAuth()))return;
await loadWorkshopData();
await initSessionId();
await loadHistory();
addMsg('system','🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.');
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>