규칙(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>
2613 lines
148 KiB
HTML
2613 lines
148 KiB
HTML
<!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 엔티티(')는
|
||
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
|
||
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
|
||
function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>').replace(/\r?\n/g,'\\n');}
|
||
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
|
||
|
||
// ── 상태 ──────────────────────────────────────────────────────────────────────
|
||
// "작업실"은 여러 프로젝트를 담는다(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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{
|
||
blocks.push('<pre><code>'+code.replace(/^\n/,'')+'</code></pre>');
|
||
return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n';
|
||
});
|
||
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
|
||
// 인라인 수식 $...$ — KaTeX가 실려 있으면 삽입 직후 renderKatexIn이 진짜 수식으로
|
||
// 그려주므로 그냥 둔다(09-24: 다른 앱들은 전부 KaTeX 실려 있었는데 작업실만 빠져 있었다).
|
||
// KaTeX가 없는 환경의 폴백으로만 이 유니코드 치환을 돌린다. 자주 나오는 LaTeX 기호를
|
||
// 유니코드로 바꾸고 $를 벗긴다. 내용에 백슬래시(LaTeX 명령)가 없으면 건드리지 않는다 —
|
||
// 가격 "$19 ... $29" 짝을 수식으로 먹어버리는 사고 방지([[reference_katex_math_rendering]]).
|
||
if(!(typeof window!=='undefined' && typeof renderKatexIn==='function' && window.katex)){
|
||
s=s.replace(/\$([^$\n]*\\[^$\n]*)\$/g,(m,body)=>{
|
||
let t=body;
|
||
t=t.replace(/\\(?:text|mathrm|mathbf)\s*\{([^{}]*)\}/g,'$1');
|
||
t=t.replace(/\\times/g,'×').replace(/\\approx/g,'≈').replace(/\\sim/g,'~')
|
||
.replace(/\\pm/g,'±').replace(/\\cdot/g,'·').replace(/\\le(?:q)?\b/g,'≤')
|
||
.replace(/\\ge(?:q)?\b/g,'≥').replace(/\\neq?\b/g,'≠')
|
||
.replace(/\\to\b|\\rightarrow\b/g,'→').replace(/\\leftarrow\b/g,'←')
|
||
.replace(/\\deg(ree)?\b/g,'°').replace(/\\mu\b/g,'μ').replace(/\\%/g,'%');
|
||
t=t.replace(/\\[a-zA-Z]+/g,'').replace(/\\([{}])/g,'$1').trim();
|
||
return t;
|
||
});
|
||
}
|
||
// 리스트 — 연속하는 [-*] 줄을 하나의 <ul>로 묶는다(항목별 <ul>이 따로 생기던 버그 수정).
|
||
s=s.replace(/(^|\n)([-*]\s+.+(?:\n[-*]\s+.+)*)/g,(m,lead,grp)=>{
|
||
const items=grp.split('\n').map(line=>'<li>'+line.replace(/^[-*]\s+/,'')+'</li>').join('');
|
||
return lead+'<ul style="padding-left:16px;margin:3px 0">'+items+'</ul>';
|
||
});
|
||
// 표 — 연속하는 파이프 라인(구분행 |---| 포함)을 <table>로. 표 지원이 없어 파이프 문자열이
|
||
// 평문으로 뭉개져 한 줄에 다 붙어 보였다(09-24 실측: 카메라 모듈 스펙 표가 그대로 복사됨).
|
||
// 구분행이 없으면 표가 아니라고 보고 그대로 둔다.
|
||
s=s.replace(/(?:\|.*\n?)+/g, grp=>{
|
||
const lines=grp.replace(/\n$/,'').split('\n').filter(l=>l.trim());
|
||
if(lines.length<2 || !/^\|[\s:|-]+$/.test(lines[1])) return grp;
|
||
const mkRow=(line,tag)=>{
|
||
const cs=line.replace(/^\s*\||\|\s*$/g,'').split('|')
|
||
.map(c=>'<'+tag+' style="border:1px solid var(--rb-border,#d5d0c8);padding:2px 8px;text-align:left;font-weight:'+(tag==='th'?'600':'400')+'">'+c.trim()+'</'+tag+'>').join('');
|
||
return '<tr>'+cs+'</tr>';
|
||
};
|
||
return '<table style="border-collapse:collapse;margin:6px 0;font-size:12px">'
|
||
+mkRow(lines[0],'th')+lines.slice(2).map(l=>mkRow(l,'td')).join('')+'</table>';
|
||
});
|
||
// 단락 — 빈 줄 단위로 split. 이미 블록 태그(<h3>/<ul>/코드 자리표시자)로 시작하면
|
||
// <p>로 감싸지 않는다.
|
||
s=s.split(/\n{2,}/).map(chunk=>{
|
||
const t=chunk.trim();
|
||
if(!t)return '';
|
||
if(/^\u0000B\d+\u0000$/.test(t))return t;
|
||
if(/^<(h3|ul|pre|table)\b/.test(t))return t;
|
||
return '<p>'+t+'</p>';
|
||
}).join('');
|
||
s=s.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[+i]||'');
|
||
return s;
|
||
}
|
||
|
||
function chatGreeting(){
|
||
// 프로젝트 이름은 addMsg→innerHTML로 들어가므로 반드시 이스케이프 — 이름에
|
||
// <script>/태그 문자가 섞이면(예: 채팅으로 지은 프로젝트 이름) 채팅창에서 실행된다.
|
||
return '🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+escHtml(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.';
|
||
}
|
||
|
||
// 프로젝트 전환(수동/생성/삭제) 시 채팅창을 그 프로젝트의 세션 기록으로 리셋한다 —
|
||
// 세션ID가 프로젝트별로 분리돼 있으므로 여기서 지우고 다시 당겨오는 것으로 충분하다.
|
||
let _chatRefreshSeq=0;
|
||
async function refreshChatForProject(){
|
||
const seq=++_chatRefreshSeq;
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
await loadHistory();
|
||
if(seq!==_chatRefreshSeq)return; // 그 사이 다른 프로젝트로 또 전환됨 — 인사말 중복 방지
|
||
addMsg('system',chatGreeting());
|
||
}
|
||
|
||
async function sendMessage(){
|
||
const input=document.getElementById('chat-input');
|
||
const msg=input.value.trim();
|
||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||
input.value='';autoResize(input);
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
function setProjectControlsBusy(busy){
|
||
for(const el of document.querySelectorAll('#project-select,.rb-hdr .hdr-btn:not(#theme-btn)'))el.disabled=busy;
|
||
}
|
||
|
||
async function callChat(message){
|
||
const btn=document.getElementById('send-btn');
|
||
btn.disabled=true;btn.textContent='...';
|
||
// 응답을 받는 동안 프로젝트를 바꾸면 채팅창이 지워져 진행 중이던 답변이 안 보인다 — 잠근다.
|
||
setProjectControlsBusy(true);
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const typingDiv=document.createElement('div');typingDiv.className='rb-msg assistant';
|
||
typingDiv.innerHTML='<div class="rb-typing"><div class="rb-dot"></div><div class="rb-dot"></div><div class="rb-dot"></div></div>';
|
||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||
let bubble=null,textBuf='';
|
||
try{
|
||
const body={
|
||
message,sessionId:getSessionId(),useTools:true,
|
||
skillContext:'이 대화는 "작업실"(여러 메이커 프로젝트 관리) 대시보드 페이지의 전용 채팅입니다. 현재 선택된 프로젝트: "'+(currentProject()?.name||'')+'". workshop_project 도구로 이 프로젝트의 부품/작업/메모를 바로 조회·수정하세요(project_name을 생략하면 이 프로젝트가 대상입니다. 다른 프로젝트를 다루려면 project_name을 명시하거나 set_active_project로 먼저 전환하세요).',
|
||
};
|
||
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||
if(!r.ok)throw new Error('서버 오류 '+r.status);
|
||
typingDiv.remove();
|
||
const msgDiv=document.createElement('div');msgDiv.className='rb-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='rb-bubble';
|
||
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
|
||
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
|
||
while(true){
|
||
const{done,value}=await reader.read();if(done)break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
// 전역 부품 배열 parts를 가리지 않게 SSE 조각은 sseParts로.
|
||
const sseParts=buf.split('\n\n');buf=sseParts.pop()||'';
|
||
for(const part of sseParts){
|
||
if(!part.startsWith('data: '))continue;
|
||
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
|
||
if(ev.type==='token'||ev.type==='text_delta'){
|
||
textBuf+=(ev.text||'');
|
||
if(bubble){bubble.innerHTML=renderMd(textBuf);if(typeof renderKatexIn==='function')renderKatexIn(bubble);msgs.scrollTop=msgs.scrollHeight;}
|
||
}
|
||
}
|
||
}
|
||
}catch(e){
|
||
typingDiv.remove();
|
||
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
|
||
}finally{btn.disabled=false;btn.textContent='전송';setProjectControlsBusy(false);}
|
||
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
|
||
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
|
||
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.
|
||
await softRefreshWorkshop();
|
||
// 09-22: 채팅으로 scad_to_stl 등 파일도구를 불러 새 파일이 생겨도 파일탭이 이미 열려있으면
|
||
// 안 새로고침되던 문제(사용자 신고: "저장되었는데 보이지 않네") — 탭 전환 없이도 갱신.
|
||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||
}
|
||
|
||
async function clearChat(){
|
||
const sid=getSessionId();
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||
addMsg('system','새 대화가 시작되었습니다.');
|
||
}
|
||
|
||
// ── 조립 설명서 탭 ────────────────────────────────────────────────────────────
|
||
// 명세(부품/단계)는 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>
|