Files
homeclaw/web-ui/html/workshop-app.html
T
kimandClaude Sonnet 5 ba193eec3c feat: 작업실 파일 폴더 규칙 통일 — STL을 .scad와 같은 폴더에, 프로젝트별 제각각 구조 정돈
규칙(workshop-layout.ts 한 곳에 정의, 도구·스크립트가 공유):
  workshop/<id>/CAD/(.scad+STL 같은 폴더) · CAD/참고/(비인쇄 참고 모델) · CAD/다운로드/ · 출력/ · 사진/ · 영상/ · 조립설명서/
  print3d/K2웹슬라이서/ · 모델검색/ · 일회성/ · profiles/
인쇄용 STL은 .scad 옆에 두되 참고용은 CAD/참고/로 분리 유지(09-22 "참고용을 인쇄 대상으로 착각" 방지)

- 이동 스크립트 scripts/migrate-workshop-layout.ts: 기본 dry-run, --apply(이동만·덮어쓰기 없음·충돌은 건너뛰고 보고),
  --undo <manifest>. .scad 경로 참조(조립 명세)와 문서 문구(CAD/print→CAD 등) 갱신, 빈 폴더만 정리. 멱등
- 도구가 같은 규칙으로 저장: print3d_model(모델→CAD/다운로드, G-code→출력, 프로젝트 없으면 print3d/일회성),
  model_download(CAD/다운로드 또는 print3d/모델검색), K2 웹 슬라이서(print3d/K2웹슬라이서). 옛 print3d/<프로젝트이름>/ 제거
- 채팅 프롬프트: STL은 .scad와 같은 폴더(옛 09-22 CAD/print 하위폴더 규칙 폐지), 프로젝트 폴더 규칙 안내
- 파일 탭: 권장 폴더를 비어 있어도 업로드 대상으로 표시하고 규칙 순서로 정렬
- 테스트 34건(규칙 표, 멱등, 충돌 미덮어쓰기, apply→undo 왕복 무손실, saveDirs, print3d 계획)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 09:37:55 +09:00

2613 lines
148 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<!-- go2rtc 라이브 플레이어(밀링 카드 ▶ 라이브용) — WebRTC→MSE→MJPEG 자동폴백 커스텀 엘리먼트 -->
<script type="module" src="/vendor/go2rtc/video-stream.js"></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;}
.rb-budget-item.total{background:rgba(245,158,11,.06);}
.rb-budget-target{display:flex;align-items:center;gap:4px;margin-top:2px;}
.rb-budget-target input{width:110px;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:2px 6px;font-size:13px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
.rb-budget-target input:focus{border-color:#f59e0b;}
.rb-gauge{height:5px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:6px;}
.rb-gauge>i{display:block;height:100%;width:0;background:#22c55e;transition:width .3s,background .3s;}
.rb-gauge.warn>i{background:#f59e0b;}
.rb-gauge.over>i{background:#ef4444;}
.rb-gauge-text{font-size:10px;color:var(--muted);margin-top:3px;}
.rb-gauge-text.over{color:#ef4444;font-weight:700;}
.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;flex-shrink:0;}
.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;}
.rb-model-pill{position:relative;flex-shrink:0;}
.rb-model-pill-btn{background:none;border:1px solid var(--line);border-radius:6px;padding:2px 8px;font-size:10px;font-weight:700;color:var(--muted);cursor:pointer;font-family:inherit;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:.15s;}
.rb-model-pill-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-model-dropdown{display:none;position:absolute;top:100%;right:0;margin-top:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.35);min-width:200px;max-height:280px;overflow-y:auto;z-index:50;padding:4px;}
.rb-model-dropdown.open{display:block;}
.rb-model-opt{padding:6px 10px;font-size:12px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);}
.rb-model-opt:hover{background:var(--panel);}
.rb-model-opt.current{color:#f59e0b;font-weight:700;}
.rb-model-dropdown-msg{padding:6px 10px;font-size:11px;color:var(--muted);}
/* ── 충돌 배너 / 모달 / 부품 표·툴바 / 진행률 (2026-09-24 업그레이드) ── */
.rb-conflict{display:none;background:rgba(239,68,68,.12);border-bottom:1px solid rgba(239,68,68,.4);padding:7px 14px;font-size:12px;color:#fca5a5;align-items:center;gap:10px;flex-wrap:wrap;flex-shrink:0;}
.rb-conflict b{color:#fecaca;}
.rb-conflict button{background:none;border:1px solid rgba(239,68,68,.6);border-radius:6px;padding:3px 10px;font-size:11px;color:#fecaca;cursor:pointer;font-family:inherit;}
.rb-conflict button:hover{background:rgba(239,68,68,.25);}
.rb-modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;align-items:center;justify-content:center;padding:16px;}
.rb-modal-bg.open{display:flex;}
.rb-modal{background:var(--panel);border:1px solid var(--line);border-radius:12px;width:min(560px,100%);max-height:80dvh;display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,.5);}
.rb-modal-hdr{display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);font-size:13px;font-weight:700;}
.rb-modal-hdr button{margin-left:auto;background:none;border:none;color:var(--muted);font-size:16px;cursor:pointer;}
.rb-modal-body{padding:12px 16px;overflow-y:auto;font-size:12px;}
.rb-modal-body h4{margin:12px 0 6px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.rb-modal-body h4:first-child{margin-top:0;}
.rb-hist-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);}
.rb-hist-row:last-child{border-bottom:none;}
.rb-hist-row .when{font-weight:600;min-width:130px;}
.rb-hist-row .sum{flex:1;color:var(--muted);font-size:11px;}
.rb-hist-row button{background:none;border:1px solid var(--line);border-radius:6px;padding:3px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-hist-row button:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-empty{color:var(--muted);padding:10px 0;font-size:12px;}
.rb-parts-bar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap;}
.rb-chip{background:none;border:1px solid var(--line);border-radius:14px;padding:3px 11px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-chip:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-chip.active{background:rgba(245,158,11,.15);border-color:#f59e0b;color:#f59e0b;font-weight:700;}
.rb-parts-bar select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--text);font-family:inherit;outline:none;}
.rb-seg{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;}
.rb-seg button{background:none;border:none;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-seg button.active{background:rgba(245,158,11,.15);color:#f59e0b;font-weight:700;}
.rb-ptable{width:100%;border-collapse:collapse;font-size:12px;}
.rb-ptable th{text-align:left;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;padding:6px 6px;border-bottom:1px solid var(--line);white-space:nowrap;}
.rb-ptable th.sortable{cursor:pointer;}
.rb-ptable th.sortable:hover{color:#f59e0b;}
.rb-ptable td{padding:3px 4px;border-bottom:1px solid var(--line);vertical-align:middle;}
.rb-ptable input[type=text],.rb-ptable input[type=number],.rb-ptable select{width:100%;box-sizing:border-box;background:transparent;border:1px solid transparent;border-radius:5px;padding:4px 6px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.rb-ptable input:hover,.rb-ptable select:hover{border-color:var(--line);}
.rb-ptable input:focus,.rb-ptable select:focus{border-color:#f59e0b;background:var(--panel-2);}
.rb-ptable td.num{text-align:right;white-space:nowrap;}
.rb-ptable td.sub{text-align:right;white-space:nowrap;color:var(--muted);padding-right:8px;}
.rb-ptable tfoot td{font-weight:700;border-bottom:none;padding-top:8px;}
.rb-price-line{font-size:11px;color:var(--muted);margin-top:8px;}
.rb-price-line .down{color:#4ade80;font-weight:700;}
.rb-price-line .up{color:#f87171;font-weight:700;}
.rb-mini-btn{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.rb-mini-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-prog{height:6px;background:var(--line);border-radius:3px;overflow:hidden;}
.rb-prog>i{display:block;height:100%;background:#22c55e;transition:width .3s;}
.rb-prog-all{margin-bottom:14px;}
.rb-prog-all .rb-prog{height:8px;}
.rb-prog-label{font-size:11px;color:var(--muted);margin-bottom:5px;display:flex;justify-content:space-between;}
.rb-phase-count{font-size:10px;color:var(--muted);flex-shrink:0;}
.rb-phase-prog{width:60px;flex-shrink:0;}
.rb-task-row input.rb-due{flex:0 0 auto;width:118px;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--muted);font-family:inherit;outline:none;}
.rb-task-row input.rb-due.overdue{border-color:#ef4444;color:#f87171;}
.rb-task-row input.rb-due.soon{border-color:#f59e0b;color:#f59e0b;}
.rb-price-item{display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);}
.rb-price-item span:first-child{flex:1;}
.rb-attach-wrap{position:relative;flex-shrink:0;}
.rb-attach-btn{background:none;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:13px;color:var(--muted);cursor:pointer;}
.rb-attach-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-attach-pop{display:none;position:absolute;bottom:100%;left:0;margin-bottom:6px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.4);width:250px;max-height:280px;overflow-y:auto;z-index:60;padding:8px;}
.rb-attach-pop.open{display:block;}
.rb-attach-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:6px;}
.rb-attach-grid img{width:100%;aspect-ratio:1/1;object-fit:cover;border-radius:6px;cursor:pointer;border:1px solid var(--line);}
.rb-attach-grid img:hover{border-color:#f59e0b;}
.rb-file-thumb img.stl-thumb{object-fit:contain;background:#2b2b2b;}
.rb-hdr-check{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:3px;cursor:pointer;flex-shrink:0;}
/* ── 조립 설명서 탭 ── */
.asm-sec{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin-bottom:14px;}
.asm-sec-hdr{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;flex-wrap:wrap;}
.asm-sec-body{padding:10px 12px;}
.asm-row{display:flex;gap:6px;align-items:center;margin-bottom:6px;flex-wrap:wrap;}
.asm-row input[type=text],.asm-row input[type=number],.asm-row select,.asm-step textarea{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;}
.asm-row input:focus,.asm-row select:focus,.asm-step textarea:focus{border-color:#f59e0b;}
.asm-part .num,.asm-chip .num{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;border-radius:11px;background:#f59e0b;color:#fff;font-weight:700;font-size:11px;padding:0 5px;flex-shrink:0;}
.asm-part input.off{width:58px;}
.asm-step{border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:var(--panel-2);}
.asm-step textarea{width:100%;box-sizing:border-box;min-height:54px;resize:vertical;margin-top:4px;line-height:1.5;}
.asm-chips{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0;}
.asm-chip{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:14px;padding:2px 10px 2px 4px;font-size:11px;cursor:pointer;background:var(--panel);user-select:none;}
.asm-chip.on{border-color:#f59e0b;background:rgba(245,158,11,.12);}
.asm-chip input{display:none;}
.asm-lbl{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-top:6px;}
.asm-imgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin-top:8px;}
.asm-imgs figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fff;}
.asm-imgs img{display:block;width:100%;height:auto;}
.asm-imgs figcaption{font-size:10px;color:#666;text-align:center;padding:2px;}
.asm-warn{font-size:11px;color:#f59e0b;margin-top:6px;}
.asm-mods{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
@media (max-width:900px){
.rb-budget-target input{width:90px;}
.rb-ptable{font-size:11px;}
}
@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;position:relative;}
.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-live-btn{position:absolute;right:8px;bottom:8px;background:rgba(0,0,0,.55);color:#fff;border:1px solid rgba(255,255,255,.3);border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;z-index:2;}
.rb-equip-live-btn:hover{background:rgba(245,158,11,.85);border-color:transparent;}
.rb-equip-live-btn.on{background:rgba(239,68,68,.8);border-color:transparent;}
.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;}
/* ── 파일 ── */
.rb-files-toolbar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap;}
.rb-files-toolbar 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-upload-btn{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-weight:700;display:inline-flex;align-items:center;gap:4px;}
.rb-upload-btn:hover{background:rgba(245,158,11,.25);}
.rb-files-dropzone{border:2px dashed var(--line);border-radius:10px;padding:18px;text-align:center;font-size:12px;color:var(--muted);margin-bottom:14px;transition:.15s;}
.rb-files-dropzone.dragover{border-color:#f59e0b;color:#f59e0b;background:rgba(245,158,11,.05);}
.rb-files-empty{font-size:12px;color:var(--muted);padding:20px 0;text-align:center;}
.rb-files-group{margin-bottom:18px;}
.rb-files-group-hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
.rb-files-group-name{font-size:12px;font-weight:700;color:var(--text);}
.rb-files-group-count{font-size:10px;color:var(--muted);}
.rb-files-group-del{margin-left:auto;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);}
.rb-files-group-del:hover{color:#ef4444;}
.rb-files-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;}
.rb-file-card{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);position:relative;}
.rb-file-thumb{width:100%;aspect-ratio:1/1;background:var(--panel-2);display:flex;align-items:center;justify-content:center;overflow:hidden;}
.rb-file-thumb img{width:100%;height:100%;object-fit:cover;}
.rb-file-thumb .rb-file-icon{font-size:28px;opacity:.6;}
.rb-file-info{padding:6px 8px;}
.rb-file-name{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.rb-file-size{font-size:10px;color:var(--muted);}
.rb-file-del{position:absolute;top:4px;right:4px;background:rgba(0,0,0,.55);border:none;border-radius:5px;color:#fff;font-size:10px;padding:2px 5px;cursor:pointer;opacity:0;transition:.15s;}
.rb-file-card:hover .rb-file-del{opacity:1;}
.rb-file-del:hover{background:#ef4444;}
.rb-file-card a{display:block;color:inherit;text-decoration:none;}
/* STL 카드 전용 — 슬라이서로 보낼 파일 다중선택 체크박스(좌상단). 평소엔 델버튼처럼
숨어있다 호버 시 보이되, 체크된 상태면 계속 보임(선택 중임을 표시). */
.rb-file-stl-sel{position:absolute;top:4px;left:4px;z-index:2;width:16px;height:16px;cursor:pointer;opacity:0;transition:.15s;accent-color:#f59e0b;}
.rb-file-card:hover .rb-file-stl-sel,.rb-file-stl-sel:checked{opacity:1;}
.rb-file-card.stl-selected{outline:2px solid #f59e0b;outline-offset:-1px;}
</style>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
</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>
<button class="hdr-btn" onclick="duplicateProjectPrompt()" title="구조를 그대로 복제(작업 완료·부품 상태는 초기화)">⧉ 복제</button>
<button class="hdr-btn" id="archive-btn" onclick="toggleArchiveProject()" title="목록에서 숨김(데이터·파일은 유지)">📦 보관</button>
<button class="hdr-btn" onclick="openHistory()" title="변경 이력 / 되돌리기 / 삭제된 프로젝트 복구">🕘 이력</button>
<label class="rb-hdr-check" title="보관된 프로젝트도 목록에 표시"><input type="checkbox" id="show-archived" onchange="toggleShowArchived(this.checked)">보관 포함</label>
<span class="rb-hdr-sp"></span>
<span class="rb-save-indicator" id="save-indicator"></span>
<button class="hdr-btn" id="theme-btn" onclick="toggleTheme()">🌙</button>
</div>
<div class="rb-conflict" id="conflict-banner"></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 total"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
<div class="rb-budget-item" style="min-width:170px">
<div class="rb-budget-label">목표 예산</div>
<div class="rb-budget-target"><input type="number" min="0" step="10000" id="budget-input" placeholder="미설정" oninput="onBudgetChange()"><span style="font-size:12px">원</span></div>
<div class="rb-gauge" id="budget-gauge" style="display:none"><i id="budget-gauge-bar"></i></div>
<div class="rb-gauge-text" id="budget-gauge-text"></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="assembly" onclick="switchTab('assembly')">조립</button>
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
<button class="rb-tab-btn" data-tab="files" onclick="switchTab('files')">파일</button>
<button class="rb-tab-btn" data-tab="overview" onclick="switchTab('overview')">개요</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 class="rb-parts-bar">
<div class="rb-seg" id="parts-view-seg">
<button data-view="card" onclick="setPartsView('card')">카드</button>
<button data-view="table" onclick="setPartsView('table')">표</button>
</div>
<span id="parts-chips"></span>
<select id="parts-sort" onchange="setPartsSort(this.value)" title="정렬(화면 표시만 바뀌고 저장 순서는 유지)">
<option value="order">등록순</option>
<option value="name">이름순</option>
<option value="amount">금액 큰 순</option>
<option value="status">상태순</option>
</select>
<span class="rb-hdr-sp"></span>
<button class="rb-mini-btn" onclick="exportBom('csv')" title="엑셀에서 열 수 있는 CSV">⬇ CSV</button>
<button class="rb-mini-btn" onclick="exportBom('md')" title="마크다운 표">⬇ MD</button>
</div>
<div id="parts-container"></div>
<button class="rb-add-btn" onclick="addPart()">+ 부품 추가</button>
</div>
<div class="rb-pane" id="pane-tasks">
<div class="rb-prog-all" id="tasks-progress"></div>
<div id="phases-container"></div>
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
</div>
<div class="rb-pane" id="pane-assembly"></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">
<video id="k2-live" autoplay muted playsinline style="display:none;width:100%;height:100%;object-fit:contain"></video>
<img id="k2-frame" alt="" style="display:none">
<span class="rb-equip-placeholder" id="k2-placeholder">불러오는 중...</span>
<button class="rb-equip-live-btn" id="k2-live-btn" onclick="toggleK2Live()">▶ 라이브</button>
</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">
<video-stream id="mill-live" mode="webrtc,mse,mp4,mjpeg" style="display:none;width:100%;height:100%;background:#000;"></video-stream>
<img id="mill-frame" alt="" style="display:none" onerror="millFrameError()" onload="millFrameLoaded()">
<span class="rb-equip-placeholder" id="mill-placeholder">불러오는 중...</span>
<button class="rb-equip-live-btn" id="mill-live-btn" onclick="toggleMillLive()">▶ 라이브</button>
</div>
<div class="rb-equip-status">20초마다 자동 새로고침(스냅샷) — <a href="/html/milling-app.html" target="_blank" style="color:#f59e0b">밀링 앱에서 라이브로 보기 ↗</a><span id="mill-live-marker"></span></div>
</div>
</div>
</div>
<div class="rb-pane" id="pane-files">
<div class="rb-files-toolbar">
<select id="upload-folder-select"></select>
<button class="hdr-btn" onclick="promptNewFolder()">+ 새 폴더</button>
<label class="rb-upload-btn">📎 파일 업로드<input type="file" id="file-input" multiple style="display:none" onchange="handleFileInputChange(event)"></label>
<button class="hdr-btn" id="stl-slice-btn" onclick="openSlicerForSelected()" disabled>🔪 선택 STL 슬라이서로 (<span id="stl-sel-count">0</span>)</button>
</div>
<div class="rb-files-dropzone" id="files-dropzone">여기로 파일을 끌어다 놓거나 위 버튼으로 업로드 (사진/CAD/STL 등, 최대 100MB)</div>
<div id="files-list-container"></div>
</div>
<div class="rb-pane" id="pane-overview">
<textarea class="rb-notes" id="overview-textarea" placeholder="프로젝트 개요 — 무엇을 만드는지/목표/구성. 채팅에서 '개요 써줘'로도 쓸 수 있다." oninput="onOverviewChange()"></textarea>
</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>
<div class="rb-model-pill">
<button class="rb-model-pill-btn" id="model-pill-btn" onclick="toggleModelDropdown(event)" title="모델 변경">-</button>
<div class="rb-model-dropdown" id="model-dropdown"></div>
</div>
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div>
<div class="rb-msgs" id="chat-msgs"></div>
<div class="rb-input-bar">
<div class="rb-attach-wrap">
<button class="rb-attach-btn" onclick="toggleAttachPop(event)" title="이 프로젝트의 사진을 채팅에 첨부(모델이 직접 봄)">📷</button>
<div class="rb-attach-pop" id="attach-pop"></div>
</div>
<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>
<div class="rb-modal-bg" id="modal-bg" onclick="if(event.target===this)closeModal()">
<div class="rb-modal">
<div class="rb-modal-hdr"><span id="modal-title"></span><button onclick="closeModal()">✕</button></div>
<div class="rb-modal-body" id="modal-body"></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{}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 엔티티(&#39;)는
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\r?\n/g,'\\n');}
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 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');
if(tab==='equip')startEquipTimers();else stopEquipTimers();
if(tab==='files')loadFiles();
if(tab==='assembly')refreshAssemblyPane(true);
}
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
// "장비" 탭을 보고 있을 때만 폴링한다 — 계속 켜두면 K2는 매번 짧은 WebRTC 연결을,
// 밀링은 go2rtc 프레임 요청을 반복하니 안 보는 동안은 자원을 안 쓰는 게 맞다.
const MILL_CHANNEL_ID=1000;
const MILL_GO2RTC_SRC='cam'; // go2rtc(127.0.0.1:1985) 릴레이 스트림 이름(config nvr.extraCameras.1000.go2rtcSrc)
// 밀링 카드 라이브(2026-09-24) — go2rtc video-stream 커스텀 엘리먼트(WebRTC→MSE→MJPEG
// 자동폴백)를 nvr-app 라이브 모달과 동일 패턴으로 쓴다. go2rtc(1985)에 브라우저가
// 직접 붙으면 Origin 검사로 거부되므로 같은 오리진 WS 프록시(/api/nvr/go2rtc-ws)를 거친다.
// https 페이지에서 LAN IP를 ws://로 하드코딩하면 mixed-content로 막히므로 반드시
// 현재 오리진 기준 상대경로로 만든다(K2 카메라와 동일 패턴, 09-18 발견).
const GO2RTC_WS=(location.protocol==='https:'?'wss://':'ws://')+location.host+'/api/nvr/go2rtc-ws';
let millLive=false;
function toggleMillLive(){
if(millLive)stopMillLive();else startMillLive();
}
function startMillLive(){
if(millLive)return;
const vs=document.getElementById('mill-live');
millLive=true;
const btn=document.getElementById('mill-live-btn');
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
document.getElementById('mill-frame').style.display='none';
document.getElementById('mill-placeholder').style.display='none';
vs.style.display='block';
vs.src=GO2RTC_WS+'?src='+encodeURIComponent(MILL_GO2RTC_SRC)+'&token='+encodeURIComponent(getToken());
document.getElementById('mill-live-marker').innerHTML=' · <b style="color:#ef4444">● 실시간</b>';
}
function stopMillLive(){
if(!millLive)return;
millLive=false;
const vs=document.getElementById('mill-live');
try{vs.disconnectedCallback();}catch{} // WS/WebRTC 연결 정리(라이브러리 내부 정리 로직 재사용, nvr-app closeLive와 동일)
vs.style.display='none';
const btn=document.getElementById('mill-live-btn');
btn.classList.remove('on');btn.textContent='▶ 라이브';
document.getElementById('mill-live-marker').innerHTML='';
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔 안 그래도 된다.
const pane=document.getElementById('pane-equip');
if(pane&&pane.classList.contains('active'))refreshMillWidget();
}
let equipTimer=null;
function startEquipTimers(){
if(equipTimer)return;
refreshK2Widget();
refreshMillWidget();
equipTimer=setInterval(()=>{if(document.hidden)return;refreshK2Widget();refreshMillWidget();},20000);
}
function stopEquipTimers(){
clearInterval(equipTimer);
equipTimer=null;
stopK2Live(); // 장비 탭을 벗어나면 라이브 WebRTC 연결도 닫는다(안 보는 동안 자원 안 쓰기)
stopMillLive();
}
// 백그라운드 탭에선 폴링을 멈췄다가 다시 보이면 즉시 한 번 갱신한다.
document.addEventListener('visibilitychange',()=>{
if(!document.hidden&&equipTimer){refreshK2Widget();refreshMillWidget();}
});
function refreshMillWidget(){
if(millLive)return; // 라이브 중엔 스냅샷 폴링 건너뜀 — 실시간 비디오가 이미 떠있고 간섭·낭비 방지
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);
});
}
// ── K2 라이브 뷰(2026-09-24) ─────────────────────────────────────────────────
// ▶ 라이브 버튼으로 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직
// 이식). 라이브 중엔 20초 스냅샷 그랩을 멈추고(refreshK2Widget 참조), 장비 탭을
// 벗어나면 stopEquipTimers가 연결을 닫는다 — 안 보는 동안엔 자원을 안 쓴다.
let k2LivePC=null;
function k2Note(msg){
const ph=document.getElementById('k2-placeholder');
const img=document.getElementById('k2-frame');
const video=document.getElementById('k2-live');
video.style.display='none';
img.style.display='none';
ph.style.display='block';
ph.textContent=msg;
}
function toggleK2Live(){
if(k2LivePC)stopK2Live();else startK2Live();
}
function startK2Live(){
if(k2LivePC)return;
const video=document.getElementById('k2-live');
const btn=document.getElementById('k2-live-btn');
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
const ph=document.getElementById('k2-placeholder');
ph.style.display='none';
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS failed,
// bytesReceived 0) — k2-app.html에서 2026-09-18 확인된 퀴크라 그대로 유지.
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
k2LivePC=pc;
pc.ontrack=(ev)=>{
if(k2LivePC!==pc)return;
video.srcObject=ev.streams[0];
video.play().catch(()=>{});
ph.style.display='none';
};
pc.oniceconnectionstatechange=()=>{
if(k2LivePC!==pc)return;
const st=pc.iceConnectionState;
if(st==='failed'){
stopK2Live();
k2Note('라이브 연결 실패 — ▶ 라이브를 다시 눌러주세요');
}else if(st==='disconnected'){
k2Note('라이브 신호 끊김(일시적) — 복구되면 그려집니다');
}
};
// 오퍼는 ICE gathering 완전종료(null candidate) 또는 1.5초 중 빠른 쪽에 보낸다 —
// 완전종료가 이 네트워크에서 20초+ 걸리는 k2-app.html의 실측 퀴크 그대로.
let offerSent=false;
const sendOfferOnce=()=>{
if(offerSent||k2LivePC!==pc)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'&&k2LivePC===pc)pc.setRemoteDescription(new RTCSessionDescription(res));
}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
};
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
pc.addTransceiver('video',{direction:'sendrecv'});
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
}
function stopK2Live(){
if(!k2LivePC)return;
try{k2LivePC.close();}catch{}
k2LivePC=null;
const video=document.getElementById('k2-live');
try{video.pause();video.srcObject=null;}catch{}
video.style.display='none';
const btn=document.getElementById('k2-live-btn');
btn.classList.remove('on');btn.textContent='▶ 라이브';
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔
// 안 그래도 된다(안 보는 탭에 프레임 그랩을 트리거하는 셈).
const pane=document.getElementById('pane-equip');
if(pane&&pane.classList.contains('active'))refreshK2Widget();
}
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');
// 라이브 연결이 살아있으면 프레임 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
// 실시간으로 흐르고 있고, 짧은 연결을 또 열면 둘이 간섭한다. 상태 조회는 계속한다.
if(!k2LivePC){
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>';
}
if(k2LivePC)html+=' · <b style="color:#ef4444">● 실시간</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();
}
// ── 보기 상태(화면 전용, 저장 안 함) — 카드/표, 상태 필터, 정렬 ──
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';
}
}
// ── 작업(단계) ────────────────────────────────────────────────────────────────
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();
}
// ── 파일 ──────────────────────────────────────────────────────────────────────
// 부품/작업/메모와 달리 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);
});
})();
// ── 메모 ──────────────────────────────────────────────────────────────────────
function onNotesChange(){
notes=document.getElementById('notes-textarea').value;
scheduleSave();
}
// ── 개요 ──────────────────────────────────────────────────────────────────────
// 메모 탭과 같은 구조 — 프로젝트 서술형 설명(description). 채팅 도구 set_description
// 으로도 쓸 수 있고, 채팅 응답 후 softRefreshWorkshop이 갱신해준다.
function onOverviewChange(){
overviewDesc=document.getElementById('overview-textarea').value;
scheduleSave();
}
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
// 세션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 escHtml(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// 부품 참고링크 href용 — http(s)(프로토콜 상대 포함)만 통과시키고 나머지는 빈 값으로
// 뭉갠다. javascript:/data: 스킴 링크는 채팅 도구(add_link)나 오타로도 들어올 수 있고
// href에 그대로 쓰면 클릭 한 번에 스크립트가 돈다.
function safeUrl(u){u=String(u??'').trim();return /^(https?:|\/\/)/i.test(u)?u:'';}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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','새 대화가 시작되었습니다.');
}
// ── 조립 설명서 탭 ────────────────────────────────────────────────────────────
// 명세(부품/단계)는 project.assembly에 들어 있어 저장/이력/충돌감지가 다른 데이터와 같다. 그림은 서버가
// OpenSCAD로 렌더해 workshop/<id>/조립설명서/ 에 저장한다(설명서 페이지는 assembly-manual.html).
let asmInfo=null; // 서버 조회 결과(그림 URL, stale 등)
let asmScanMods=null; // 스캔한 .scad module 목록
let asmScadFiles=[]; // 프로젝트 하위 .scad 후보
let asmBusy=false;
let asmLoadSeq=0;
let asmAutoDone=''; // 자동 연결/초안을 이미 시도한 프로젝트 id(한 번만)
let asmDraftInfo=null; // 마지막 자동 초안의 제외/경고 안내
let asmLastError='';
function asmPaneActive(){return document.getElementById('pane-assembly').classList.contains('active');}
let asmProjectId='';
function refreshAssemblyPane(forceLoad){
const pid=(currentProject()||{}).id||'';
if(asmProjectId!==pid){asmInfo=null;asmScanMods=null;asmScadFiles=[];asmProjectId=pid;asmDraftInfo=null;asmLastError='';} // 프로젝트가 바뀌면 그림 정보 초기화
if(!asmPaneActive())return;
renderAssembly();
if(forceLoad||asmInfo===null)loadAssemblyInfo();
}
function ensureAssembly(){
if(!assembly)assembly={scad:'',view:{rx:60,rz:35},explode:1,parts:[],steps:[]};
return assembly;
}
async function loadAssemblyInfo(){
const proj=currentProject();
if(!proj)return;
const seq=++asmLoadSeq;
try{
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id),{headers:authH()});
if(!r.ok)return;
const d=await r.json();
if(seq!==asmLoadSeq)return;
asmInfo=d;
const fr=await fetch('/api/workshop/assembly-scad-files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
if(fr.ok)asmScadFiles=(await fr.json()).files||[];
if(seq!==asmLoadSeq||!asmPaneActive())return;
renderAssemblyImages();
await asmAutoLink();
}catch{}
}
// .scad 자동 연동: ① 조립 모델이 비어 있으면 프로젝트의 가장 최근 .scad를 자동 연결하고, 부품이 없으면 초안까지
// 만들어 그림을 렌더한다 ② 이미 연결돼 있고 .scad가 바뀌어 그림이 오래됐으면(autoRender 켜짐) 자동 재렌더.
// 사용자가 만든 내용은 덮어쓰지 않는다(빈 명세일 때만 채움).
async function asmAutoLink(){
if(asmBusy)return;
const a=assembly;
if(!a||!a.scad){
if(asmAutoDone===asmProjectId||!asmScadFiles.length)return;
asmAutoDone=asmProjectId;
ensureAssembly().scad=asmScadFiles[0];
asmMarkChanged();
renderAssembly();
if(!assembly.parts.length&&await asmDraft(true))await asmRender(true);
return;
}
// 연결만 돼 있고 부품/단계가 하나도 없으면(자동 연결 직후 중단됐거나 수동으로 경로만 넣은 경우) 초안을 한 번 시도
if(a.scad&&!a.parts.length&&!a.steps.length&&asmAutoDone!==asmProjectId+':draft'){
asmAutoDone=asmProjectId+':draft';
if(await asmDraft(true))await asmRender(true);
return;
}
if(asmInfo&&asmInfo.stale&&a.autoRender!==false&&a.parts.length&&a.steps.length&&asmAutoDone!==asmProjectId+':stale'){
asmAutoDone=asmProjectId+':stale'; // 실패해도 이 화면에서 무한 재시도하지 않는다
await asmRender(true);
}
}
function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;scheduleSave();renderAsmStatus();}
function renderAssembly(){
const pane=document.getElementById('pane-assembly');
const a=assembly;
const bomNames=parts.map(p=>p.name).filter(Boolean);
const partRows=(a?a.parts:[]).map((p,i)=>`
<div class="asm-row asm-part" data-id="${escAttr(p.id)}">
<span class="num">${i+1}</span>
<input type="text" style="flex:1;min-width:110px" placeholder="부품 이름" value="${escAttr(p.name)}" oninput="asmPart('${p.id}','name',this.value)">
<input type="text" style="flex:1.2;min-width:120px;font-family:monospace" placeholder="모듈 호출 예: camera(80)" value="${escAttr(p.call)}" oninput="asmPart('${p.id}','call',this.value)" title=".scad의 조립 좌표 모듈 호출">
<span style="font-size:10px;color:var(--muted)">분해</span>
${[0,1,2].map(k=>`<input type="number" class="off" title="${'XYZ'[k]} 방향으로 띄울 거리(mm)" value="${escAttr(p.offset[k])}" oninput="asmOffset('${p.id}',${k},this.value)">`).join('')}
<select title="대시보드 BOM 부품과 연결(구매 상태 표시)" onchange="asmPart('${p.id}','bom',this.value)">
<option value="">BOM 연결 없음</option>
${bomNames.map(n=>`<option value="${escAttr(n)}" ${p.bom===n?'selected':''}>${escAttr(n)}</option>`).join('')}
${p.bom&&!bomNames.includes(p.bom)?`<option value="${escAttr(p.bom)}" selected>${escAttr(p.bom)}</option>`:''}
</select>
<input type="number" min="1" style="width:52px" title="수량" value="${escAttr(p.qty)}" oninput="asmPart('${p.id}','qty',this.value)">
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',-1)" title="위로(번호 변경)">↑</button>
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',1)" title="아래로(번호 변경)">↓</button>
<button class="rb-del-btn" onclick="asmDelPart('${p.id}')">✕</button>
</div>`).join('')||'<div class="rb-empty">부품이 없습니다. 위에서 .scad를 스캔해 모듈을 추가하거나 직접 추가하세요.</div>';
const stepCards=(a?a.steps:[]).map((st,i)=>`
<div class="asm-step" data-id="${escAttr(st.id)}">
<div class="asm-row" style="margin-bottom:2px">
<b style="color:#f59e0b">STEP ${i+1}</b>
<input type="text" style="flex:1;min-width:140px" placeholder="단계 제목" value="${escAttr(st.title)}" oninput="asmStep('${st.id}','title',this.value)">
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',-1)">↑</button>
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',1)">↓</button>
<button class="rb-del-btn" onclick="asmDelStep('${st.id}')">✕</button>
</div>
<textarea placeholder="작업 내용(어떻게 끼우고 조이는지)" oninput="asmStep('${st.id}','desc',this.value)">${escAttr(st.desc)}</textarea>
<div class="asm-lbl">이번 단계에 붙는 부품</div>
<div class="asm-chips">${a.parts.length?a.parts.map((p,k)=>`<label class="asm-chip${st.partIds.includes(p.id)?' on':''}"><input type="checkbox" ${st.partIds.includes(p.id)?'checked':''} onchange="asmToggleStepPart('${st.id}','${p.id}',this.checked)"><span class="num">${k+1}</span>${escAttr(p.name)}</label>`).join(''):'<span class="rb-empty">먼저 부품을 추가하세요</span>'}</div>
<div class="asm-lbl">체결 부품 (한 줄에 하나: 이름 | 수량)</div>
<textarea style="min-height:38px" placeholder="M4x10 볼트 | 4" oninput="asmFasteners('${st.id}',this.value)">${escAttr((st.fasteners||[]).map(f=>f.name+' | '+f.qty).join('\n'))}</textarea>
<div class="asm-lbl">공구 (쉼표로 구분)</div>
<div class="asm-row"><input type="text" style="flex:1" placeholder="2.5mm 육각렌치, 니퍼" value="${escAttr((st.tools||[]).join(', '))}" oninput="asmTools('${st.id}',this.value)"></div>
</div>`).join('')||'<div class="rb-empty">단계가 없습니다. "+ 단계 추가"를 누르세요.</div>';
pane.innerHTML=`
<div class="asm-sec">
<div class="asm-sec-hdr">📐 조립 모델(.scad) <span style="font-weight:400;color:var(--muted)">— 프로젝트의 OpenSCAD 조립 파일. 원본은 수정하지 않고 부품만 골라 그립니다.</span></div>
<div class="asm-sec-body">
<div class="asm-row">
<input type="text" id="asm-scad" list="asm-scad-list" style="flex:1;min-width:220px" placeholder="workshop/<프로젝트id>/CAD/xxx.scad" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
<datalist id="asm-scad-list">${asmScadFiles.map(f=>`<option value="${escAttr(f)}">`).join('')}</datalist>
<button class="rb-mini-btn" onclick="asmScan()">🔍 모듈 스캔</button>
<button class="rb-mini-btn" onclick="asmDraft(false)" title=".scad의 조립 블록에서 부품·분해 위치·조립 순서를 자동으로 뽑습니다">✨ 자동 초안</button>
</div>
<div class="asm-row">
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;cursor:pointer" title="채팅에서 .scad를 수정하거나, 이 탭을 열었을 때 그림이 오래됐으면 자동으로 다시 렌더링합니다">
<input type="checkbox" ${!a||a.autoRender!==false?'checked':''} onchange="asmSet('autoRender',this.checked)">.scad가 바뀌면 그림 자동 갱신</label>
<span style="font-size:10px;color:var(--muted)">${asmScadFiles.length>1?'· 이 프로젝트의 .scad '+asmScadFiles.length+'개 (입력칸에서 선택)':''}</span>
</div>
<div id="asm-draft-note">${asmDraftNoteHtml()}</div>
<div class="asm-row">
<span style="font-size:11px;color:var(--muted)">보는 각도</span>
<input type="number" style="width:64px" title="위에서 내려다보는 각도(0~90)" value="${escAttr(a?a.view.rx:60)}" oninput="asmView('rx',this.value)">
<input type="number" style="width:64px" title="회전 각도" value="${escAttr(a?a.view.rz:35)}" oninput="asmView('rz',this.value)">
<span style="font-size:11px;color:var(--muted);margin-left:8px">분해 간격 ×</span>
<input type="number" step="0.1" min="0.1" max="5" style="width:64px" value="${escAttr(a?a.explode:1)}" oninput="asmSet('explode',this.value)">
</div>
<div id="asm-scan-out">${asmScanHtml()}</div>
</div>
</div>
<div class="asm-sec">
<div class="asm-sec-hdr">🔩 부품 <span style="font-weight:400;color:var(--muted)">번호 = 순서 (그림의 풍선 번호)</span><span class="rb-hdr-sp"></span>
<button class="rb-mini-btn" onclick="asmAddPart()">+ 부품 추가</button></div>
<div class="asm-sec-body">${partRows}</div>
</div>
<div class="asm-sec">
<div class="asm-sec-hdr">🪜 조립 순서<span class="rb-hdr-sp"></span>
<button class="rb-mini-btn" onclick="asmStepsToTasks()" title="STEP들을 '작업' 탭에 조립 단계로 추가">→ 작업 탭으로 가져오기</button>
<button class="rb-mini-btn" onclick="asmAddStep()">+ 단계 추가</button></div>
<div class="asm-sec-body">${stepCards}</div>
</div>
<div class="asm-sec">
<div class="asm-sec-hdr">🖼 설명서 <span id="asm-status" style="font-weight:400"></span><span class="rb-hdr-sp"></span>
<button class="rb-add-btn" style="margin:0" id="asm-render-btn" onclick="asmRender()">🖼 그림 렌더링</button>
<a class="rb-mini-btn" style="text-decoration:none" id="asm-open" href="/html/assembly-manual.html?projectId=${encodeURIComponent((currentProject()||{}).id||'')}" target="_blank">📄 설명서 열기 / 인쇄</a></div>
<div class="asm-sec-body">
<div class="rb-empty" style="padding:0 0 6px">💬 채팅에 <b>"조립 설명서 만들어줘"</b>라고 하면 AI가 .scad를 읽고 부품/단계 초안을 잡아 줍니다.</div>
<div id="asm-imgs"></div>
</div>
</div>`;
renderAsmStatus();
renderAssemblyImages();
}
function renderAsmStatus(){
const el=document.getElementById('asm-status');
if(!el)return;
const a=assembly;
let t='';
if(asmBusy)t='<span style="color:#f59e0b">렌더링 중… (부품 수에 따라 10~60초)</span>';
else if(asmLastError)t='<span style="color:#ef4444">⚠ '+escHtml(asmLastError)+'</span>';
else if(!a||!a.parts.length||!a.steps.length)t='<span style="color:var(--muted)">부품과 단계를 채운 뒤 렌더링하세요</span>';
else if(!asmInfo||!asmInfo.rendered)t='<span style="color:var(--muted)">아직 렌더링 안 됨</span>';
else t='<span style="color:var(--muted)">마지막 렌더 '+new Date(asmInfo.renderedAt).toLocaleString('ko-KR')+'</span>'+(asmInfo.stale?' <span style="color:#f59e0b">⚠ 모델이 바뀜</span>':'')+(asmInfo.specChanged?' <span style="color:#f59e0b">⚠ 명세 변경됨 — 다시 렌더링</span>':'');
el.innerHTML=t;
const b=document.getElementById('asm-render-btn');
if(b){b.disabled=asmBusy;b.textContent=asmBusy?'렌더링 중…':'🖼 그림 렌더링';}
}
function renderAssemblyImages(){
renderAsmStatus();
const box=document.getElementById('asm-imgs');
if(!box)return;
if(!asmInfo||!asmInfo.images){box.innerHTML='';return;}
const im=asmInfo.images;
box.innerHTML='<div class="asm-imgs">'
+'<figure><img src="'+escAttr(im.overview)+'" loading="lazy"><figcaption>조립 완성</figcaption></figure>'
+(im.exploded?'<figure><img src="'+escAttr(im.exploded)+'" loading="lazy"><figcaption>분해도</figcaption></figure>':'')
+im.steps.map((u,i)=>'<figure><img src="'+escAttr(u)+'" loading="lazy"><figcaption>STEP '+(i+1)+'</figcaption></figure>').join('')+'</div>'
+(asmInfo.unassigned&&asmInfo.unassigned.length?'<div class="asm-warn">⚠ 어느 단계에도 없는 부품: '+asmInfo.unassigned.map(escHtml).join(', ')+'</div>':'');
}
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ──
function asmSet(k,v){
const a=ensureAssembly();
a[k]=k==='explode'?(Number(v)||1):(k==='autoRender'?!!v:v);
asmMarkChanged();
}
function asmView(k,v){ensureAssembly().view[k]=Number(v)||0;asmMarkChanged();}
function asmFind(id){return ensureAssembly().parts.find(p=>p.id===id);}
function asmPart(id,k,v){
const p=asmFind(id);if(!p)return;
if(k==='qty')p.qty=Math.max(1,Math.round(Number(v)||1));
else if(k==='bom'){if(v)p.bom=v;else delete p.bom;}
else p[k]=v;
asmMarkChanged();
}
function asmOffset(id,k,v){const p=asmFind(id);if(!p)return;p.offset[k]=Number(v)||0;asmMarkChanged();}
function asmAddPart(mod){
const a=ensureAssembly();
a.parts.push({id:genId('ap'),name:mod?mod.name:'',call:mod?mod.call:'',offset:[0,0,0],qty:1});
renderAssembly();scheduleSave();
}
function asmDelPart(id){
const a=ensureAssembly();
a.parts=a.parts.filter(p=>p.id!==id);
a.steps.forEach(s=>{s.partIds=s.partIds.filter(x=>x!==id);});
renderAssembly();scheduleSave();
}
function asmMovePart(id,dir){
const a=ensureAssembly();
const i=a.parts.findIndex(p=>p.id===id),j=i+dir;
if(i<0||j<0||j>=a.parts.length)return;
[a.parts[i],a.parts[j]]=[a.parts[j],a.parts[i]];
renderAssembly();scheduleSave();
}
function asmAddStep(){
const a=ensureAssembly();
a.steps.push({id:genId('as'),title:'',desc:'',partIds:[],fasteners:[],tools:[]});
renderAssembly();scheduleSave();
}
function asmDelStep(id){const a=ensureAssembly();a.steps=a.steps.filter(s=>s.id!==id);renderAssembly();scheduleSave();}
function asmMoveStep(id,dir){
const a=ensureAssembly();
const i=a.steps.findIndex(s=>s.id===id),j=i+dir;
if(i<0||j<0||j>=a.steps.length)return;
[a.steps[i],a.steps[j]]=[a.steps[j],a.steps[i]];
renderAssembly();scheduleSave();
}
function asmStepFind(id){return ensureAssembly().steps.find(s=>s.id===id);}
function asmStep(id,k,v){const s=asmStepFind(id);if(!s)return;s[k]=v;asmMarkChanged();}
function asmToggleStepPart(stepId,partId,on){
const s=asmStepFind(stepId);if(!s)return;
s.partIds=on?[...new Set([...s.partIds,partId])]:s.partIds.filter(x=>x!==partId);
renderAssembly();
scheduleSave();
}
// "이름 | 수량" / "이름 × 수량" / "이름 x 수량" 한 줄씩 → [{name,qty}]
function asmFasteners(id,text){
const s=asmStepFind(id);if(!s)return;
s.fasteners=String(text||'').split('\n').map(l=>l.trim()).filter(Boolean).map(l=>{
const m=/^(.*?)\s*(?:[|×]\s*|\s[xX*]\s+)(\d+)\s*$/.exec(l);
return m?{name:m[1].trim(),qty:Math.max(1,Number(m[2])||1)}:{name:l,qty:1};
}).filter(f=>f.name);
asmMarkChanged();
}
function asmTools(id,text){
const s=asmStepFind(id);if(!s)return;
s.tools=String(text||'').split(',').map(t=>t.trim()).filter(Boolean);
asmMarkChanged();
}
async function asmScan(){
const a=ensureAssembly();
const out=document.getElementById('asm-scan-out');
if(!a.scad){out.innerHTML='<div class="asm-warn">.scad 경로를 먼저 입력하세요(입력칸을 누르면 프로젝트의 .scad 후보가 뜹니다).</div>';return;}
out.innerHTML='<div class="rb-empty">스캔 중…</div>';
try{
const r=await fetch('/api/workshop/assembly-scan?path='+encodeURIComponent(a.scad),{headers:authH()});
const d=await r.json();
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
asmScanMods=d.modules;
out.innerHTML=asmScanHtml();
}catch(e){out.innerHTML='<div class="asm-warn">스캔 실패: '+escHtml(e.message)+'</div>';}
}
// 스캔 결과는 부품을 추가해 패널이 다시 그려져도 남아 있어야 한다(연속으로 여러 모듈을 담는 흐름).
function asmScanHtml(){
if(!asmScanMods)return '';
const usable=asmScanMods.filter(m=>!m.isPrintPart);
return '<div class="asm-lbl">조립 좌표 모듈 — 눌러서 부품으로 추가 (part_* 는 프린트 배치용이라 제외)</div><div class="asm-mods">'
+(usable.length?usable.map((m,i)=>'<button class="rb-mini-btn" onclick="asmAddFromScan('+i+')" title="'+escAttr(m.params.join(', '))+'">+ '+escHtml(m.name)+(m.requiredParams.length?'('+escHtml(m.requiredParams.join(', '))+')':'()')+'</button>').join(''):'<span class="rb-empty">module이 없습니다.</span>')+'</div>';
}
function asmAddFromScan(i){
const m=(asmScanMods||[]).filter(x=>!x.isPrintPart)[i];
if(!m)return;
// 필수 인자가 있는 모듈은 0으로 채운 호출을 만들어 두고 사용자가 고치게 한다(예: camera(0) → camera(80)).
asmAddPart({name:m.name,call:m.name+'('+m.requiredParams.map(()=>'0').join(', ')+')'});
}
async function asmRender(silent){
const a=assembly;
if(!a||!a.parts.length||!a.steps.length){if(!silent)alert('부품과 단계를 먼저 채워 주세요.');return;}
if(asmBusy)return;
const proj=currentProject();
if(!proj)return;
asmBusy=true;renderAsmStatus();
try{
await flushNow(); // 명세 저장이 끝난 뒤 렌더(서버는 저장된 명세로 그린다)
if(conflict)throw new Error('저장 충돌을 먼저 해결하세요.');
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id)+'/render',{method:'POST',headers:authH()});
const d=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
asmInfo=d;
asmLastError='';
if(!silent&&d.warnings&&d.warnings.length)alert(d.warnings.join('\n'));
}catch(e){
asmLastError='렌더 실패: '+e.message;
if(!silent)alert(asmLastError);
}
finally{asmBusy=false;renderAssemblyImages();}
}
// 제외/경고 안내(자동 초안 결과) — 패널이 다시 그려져도 남는다
function asmDraftNoteHtml(){
if(!asmDraftInfo)return '';
const i=asmDraftInfo;
return (i.excluded.length?'<div class="asm-warn" style="color:var(--muted)">제외(투명/참고용으로 추정): '+i.excluded.map(escHtml).join(', ')+' — 필요하면 위 스캔 목록에서 직접 추가</div>':'')
+i.warnings.map(w=>'<div class="asm-warn">⚠ '+escHtml(w)+'</div>').join('');
}
// .scad에서 부품/분해 offset/조립 순서 초안을 자동으로 만든다. 반환: 성공 여부(자동 흐름이 이어서 렌더할지 판단).
async function asmDraft(auto){
const a=ensureAssembly();
if(!a.scad){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
if(!auto&&a.parts.length&&!confirm('기존 부품/단계를 자동 초안으로 덮어쓸까요?\n(이름·설명·체결부품 편집 내용이 사라집니다. 되돌리기는 🕘 이력에서 가능)'))return false;
const out=document.getElementById('asm-draft-note');
if(out)out.innerHTML='<div class="rb-empty">.scad 분석 중…</div>';
try{
const r=await fetch('/api/workshop/assembly-draft',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({scad:a.scad})});
const d=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
// 자동 흐름에서는 "조립 블록(if PART=="all")"이 있는 .scad만 초안을 채운다 — 데모/애니메이션 파일로 엉뚱한
// 명세가 조용히 만들어지는 걸 막는다(수동 ✨ 버튼은 그래도 허용하고 경고를 보여준다).
if(auto&&d.region!=='all-block'){
asmDraftInfo={excluded:[],warnings:['자동 연결만 했습니다 — 이 .scad엔 조립 블록(if (PART == "all"))이 없어 자동 초안은 만들지 않았습니다. 필요하면 ✨ 자동 초안을 직접 눌러 보세요.']};
renderAssembly();
return false;
}
const keep={scad:a.scad,autoRender:a.autoRender!==false,view:a.view,explode:a.explode};
assembly=Object.assign({},d.assembly,keep);
asmDraftInfo={excluded:d.excluded||[],warnings:d.warnings||[]};
if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;
asmLastError='';
renderAssembly();
scheduleSave();
return true;
}catch(e){
asmDraftInfo=null;
asmLastError='자동 초안 실패: '+e.message;
renderAssembly();
if(!auto)alert(asmLastError);
return false;
}
}
// STEP들을 작업 탭의 "조립" 단계로 추가(이미 같은 문구가 있으면 건너뜀)
function asmStepsToTasks(){
const a=assembly;
if(!a||!a.steps.length){alert('가져올 단계가 없습니다.');return;}
let ph=phases.find(p=>p.name==='조립');
if(!ph){ph={id:genId('ph'),name:'조립',tasks:[]};phases.push(ph);}
let added=0;
a.steps.forEach((s,i)=>{
const text='STEP '+(i+1)+': '+(s.title||'(제목 없음)');
if(!ph.tasks.some(t=>t.text===text)){ph.tasks.push({id:genId('t'),text,done:false});added++;}
});
renderPhases();scheduleSave();
alert(added?('작업 탭의 "조립" 단계에 '+added+'개를 추가했습니다.'):'이미 모두 추가돼 있습니다.');
}
// ── 사진 첨부(📷) ─────────────────────────────────────────────────────────────
// 이 프로젝트의 사진을 골라 채팅 입력에 "[첨부 사진: 경로]"를 넣는다. 서버가 이 표시를 보면
// 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');
});
// ── 모달 / 변경 이력 ─────────────────────────────────────────────────────────
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('복구됨');
}
// ── 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();
})();
</script>
</body>
</html>