feat: 로봇 프로젝트 관리 대시보드(robot-app) 추가
메카넘휠 이동로봇 프로젝트(파이5+RPLidar, 추후 팔 추가)의 부품리스트/ 단계별 작업/예산을 관리하는 웹앱. doctor-app의 GET/PUT 단일문서 패턴을 재사용하되 케이스 목록 없이 단일 프로젝트 문서로 단순화했고, 첫 로드시 project_robot_dog.md 메모 기준 실제 진행상황을 기본 시드로 반환한다. - src/gateway/routes/routes-robot.ts: GET/PUT /api/robot/project, 사용자별 workspace/.smallclaw/robot-project.json에 원자적 저장 - web-ui/html/robot-app.html: 부품/작업/메모 3탭, 실시간 예산 요약, 디바운스 자동저장 - index.html 🏠 홈 드롭다운에 🤖 로봇 링크 추가 장비연동/전용챗봇/외부데이터연동/CAD플러그인은 다음 단계로 보류. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* routes-robot.ts
|
||||||
|
* "로봇 프로젝트" 관리 대시보드 — 부품 리스트/단계별 작업/메모를 담은 단일 문서를
|
||||||
|
* 사용자별로 저장한다. doctor/lawyer 케이스 앱과 달리 여러 건이 아니라 프로젝트
|
||||||
|
* 하나뿐이라 통째로 GET/PUT하는 단일 JSON 문서 방식(케이스 배열이 아님) — 원자적
|
||||||
|
* 쓰기(tmp+rename)는 routes-doctor-cases.ts와 동일한 관용구.
|
||||||
|
*/
|
||||||
|
import express from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { getUserWorkspace } from '../../config/config';
|
||||||
|
|
||||||
|
interface RobotPart {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
unitPrice: number;
|
||||||
|
status: '검토중' | '주문완료' | '보유';
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotTask {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotPhase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tasks: RobotTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotProjectData {
|
||||||
|
parts: RobotPart[];
|
||||||
|
phases: RobotPhase[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function robotProjectPath(username: string): string {
|
||||||
|
return path.join(getUserWorkspace(username), '.smallclaw', 'robot-project.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-09-19 project_robot_dog.md 메모 기준 — 첫 로드 시 빈 화면 대신 실제 진행상황을 반영.
|
||||||
|
// 사용자가 처음 편집해서 PUT하기 전까진 파일로 저장되지 않는다(읽기 전용 기본값).
|
||||||
|
function defaultSeed(): RobotProjectData {
|
||||||
|
return {
|
||||||
|
parts: [
|
||||||
|
{ id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '' },
|
||||||
|
{ id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '' },
|
||||||
|
{ id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단' },
|
||||||
|
{ id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||||
|
{ id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '' },
|
||||||
|
{ id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용' },
|
||||||
|
{ id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '' },
|
||||||
|
{ id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '' },
|
||||||
|
{ id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '' },
|
||||||
|
{ id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||||
|
{ id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용' },
|
||||||
|
{ id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용' },
|
||||||
|
{ id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고' },
|
||||||
|
],
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
id: 'ph1', name: '1단계: 소형 프로토타입(SLAM 검증)',
|
||||||
|
tasks: [
|
||||||
|
{ id: 't1', text: '라즈베리파이5 8GB 주문', done: true },
|
||||||
|
{ id: 't2', text: 'RPLidar A1 중고 주문', done: true },
|
||||||
|
{ id: 't3', text: '메카넘휠 섀시+모터드라이버 구매', done: false },
|
||||||
|
{ id: 't4', text: 'ROS2 Nav2 SLAM 소프트웨어 검증', done: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ph2', name: '2단계: 팔 추가',
|
||||||
|
tasks: [
|
||||||
|
{ id: 't5', text: '팔 방식 최종 결정(SO-101 스케일업 vs AR4 vs 하이브리드)', done: false },
|
||||||
|
{ id: 't6', text: 'OAK-D-Lite 카메라 구매', done: false },
|
||||||
|
{ id: 't7', text: '스테퍼+리드스크류 텔레스코픽 관절 설계/제작', done: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ph3', name: 'V2: 1m급 대형화(장기)',
|
||||||
|
tasks: [
|
||||||
|
{ id: 't8', text: '필요토크 계산 및 고토크모터 스펙 확정', done: false },
|
||||||
|
{ id: 't9', text: '대형 메카넘휠/알루미늄 프로파일 프레임 설계', done: false },
|
||||||
|
{ id: 't10', text: '하드웨어 전면 교체(소프트웨어 스택은 재사용)', done: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerRobotRoutes(
|
||||||
|
app: express.Application,
|
||||||
|
getSessionUser: (req: express.Request) => any,
|
||||||
|
): void {
|
||||||
|
app.get('/api/robot/project', (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const p = robotProjectPath(session.username);
|
||||||
|
if (!fs.existsSync(p)) return res.json(defaultSeed());
|
||||||
|
try {
|
||||||
|
res.json(JSON.parse(fs.readFileSync(p, 'utf-8')));
|
||||||
|
} catch {
|
||||||
|
res.json(defaultSeed());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/robot/project', (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const { parts, phases, notes } = req.body || {};
|
||||||
|
if (!Array.isArray(parts) || !Array.isArray(phases)) {
|
||||||
|
return res.status(400).json({ error: 'parts/phases array required' });
|
||||||
|
}
|
||||||
|
const data: RobotProjectData = { parts, phases, notes: String(notes || '') };
|
||||||
|
const p = robotProjectPath(session.username);
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
const tmp = `${p}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
fs.renameSync(tmp, p);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ import { registerDentalRoutes } from './routes/routes-dental';
|
|||||||
import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
|
import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
|
||||||
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
|
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
|
||||||
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
|
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
|
||||||
|
import { registerRobotRoutes } from './routes/routes-robot';
|
||||||
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
|
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
|
||||||
import {
|
import {
|
||||||
getConfig,
|
getConfig,
|
||||||
@@ -4611,6 +4612,7 @@ registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir);
|
|||||||
registerWopiRoutes(app, '/api/lawyer');
|
registerWopiRoutes(app, '/api/lawyer');
|
||||||
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
|
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
|
||||||
registerWopiRoutes(app, '/api/doctor');
|
registerWopiRoutes(app, '/api/doctor');
|
||||||
|
registerRobotRoutes(app, getSessionUser);
|
||||||
|
|
||||||
|
|
||||||
// Redirect legacy top-level HTML routes into /html/ after web-ui reorganization.
|
// Redirect legacy top-level HTML routes into /html/ after web-ui reorganization.
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<button onclick="window.open('/html/applecherry-app.html','_blank')">🌸 애플체리</button>
|
<button onclick="window.open('/html/applecherry-app.html','_blank')">🌸 애플체리</button>
|
||||||
<button onclick="window.open('/html/k2-app.html','_blank')">🖨️ K2 콤보</button>
|
<button onclick="window.open('/html/k2-app.html','_blank')">🖨️ K2 콤보</button>
|
||||||
<button onclick="window.open('/html/milling-app.html','_blank')">⚙️ CNC 밀링</button>
|
<button onclick="window.open('/html/milling-app.html','_blank')">⚙️ CNC 밀링</button>
|
||||||
|
<button onclick="window.open('/html/robot-app.html','_blank')">🤖 로봇</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
|
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🤖 로봇 프로젝트</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||||||
|
<link rel="stylesheet" href="../css/styles.css">
|
||||||
|
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
|
||||||
|
<style>
|
||||||
|
html,body{height:100%;margin:0;padding:0;}
|
||||||
|
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
||||||
|
|
||||||
|
.rb-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||||||
|
.rb-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||||||
|
.rb-hdr-sp{flex:1;}
|
||||||
|
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
||||||
|
.hdr-btn:hover{border-color:#f59e0b;color:#f59e0b;}
|
||||||
|
.rb-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
|
||||||
|
|
||||||
|
.rb-budget{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
|
||||||
|
.rb-budget-item{flex:1;min-width:120px;padding:8px 14px;border-right:1px solid var(--line);}
|
||||||
|
.rb-budget-item:last-child{border-right:none;background:rgba(245,158,11,.06);}
|
||||||
|
.rb-budget-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.rb-budget-val{font-size:15px;font-weight:700;margin-top:2px;}
|
||||||
|
|
||||||
|
.rb-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-parts-table{width:100%;border-collapse:collapse;font-size:12px;}
|
||||||
|
.rb-parts-table th{text-align:left;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:4px 6px;border-bottom:1px solid var(--line);}
|
||||||
|
.rb-parts-table td{padding:4px 6px;border-bottom:1px solid var(--line);vertical-align:middle;}
|
||||||
|
.rb-parts-table input[type=text],.rb-parts-table input[type=number]{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:4px 6px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
|
||||||
|
.rb-parts-table input:focus,.rb-parts-table select:focus{border-color:#f59e0b;}
|
||||||
|
.rb-parts-table select{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:4px 6px;font-size:11px;color:var(--text);font-family:inherit;}
|
||||||
|
.rb-col-qty{width:52px;}.rb-col-price{width:100px;}.rb-col-status{width:90px;}.rb-col-sub{width:90px;text-align:right;color:var(--muted);}.rb-col-del{width:28px;}
|
||||||
|
.rb-del-btn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:2px 4px;}
|
||||||
|
.rb-del-btn:hover{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-notes{width:100%;height:calc(100dvh - 220px);box-sizing:border-box;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;}
|
||||||
|
.rb-notes:focus{border-color:#f59e0b;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="rb-hdr">
|
||||||
|
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
|
||||||
|
<h1>🤖 로봇 프로젝트</h1>
|
||||||
|
<span class="rb-hdr-sp"></span>
|
||||||
|
<span class="rb-save-indicator" id="save-indicator"></span>
|
||||||
|
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rb-budget">
|
||||||
|
<div class="rb-budget-item"><div class="rb-budget-label">검토중</div><div class="rb-budget-val" id="sum-review">0원</div></div>
|
||||||
|
<div class="rb-budget-item"><div class="rb-budget-label">주문완료</div><div class="rb-budget-val" id="sum-ordered">0원</div></div>
|
||||||
|
<div class="rb-budget-item"><div class="rb-budget-label">보유</div><div class="rb-budget-val" id="sum-owned">0원</div></div>
|
||||||
|
<div class="rb-budget-item"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rb-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="notes" onclick="switchTab('notes')">메모</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rb-body">
|
||||||
|
<div class="rb-pane active" id="pane-parts">
|
||||||
|
<table class="rb-parts-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>이름</th><th class="rb-col-qty">수량</th><th class="rb-col-price">단가</th>
|
||||||
|
<th class="rb-col-status">상태</th><th>메모</th><th class="rb-col-sub">소계</th><th class="rb-col-del"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="parts-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<button class="rb-add-btn" onclick="addPart()">+ 부품 추가</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rb-pane" id="pane-tasks">
|
||||||
|
<div id="phases-container"></div>
|
||||||
|
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rb-pane" id="pane-notes">
|
||||||
|
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
const TOKEN_KEY='smallclaw_token';
|
||||||
|
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||||||
|
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
|
||||||
|
async function checkAuth(){
|
||||||
|
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||||||
|
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
|
||||||
|
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||||||
|
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
|
||||||
|
|
||||||
|
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||||||
|
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
|
||||||
|
|
||||||
|
// ── 상태 ──────────────────────────────────────────────────────────────────────
|
||||||
|
let parts=[];
|
||||||
|
let phases=[];
|
||||||
|
let notes='';
|
||||||
|
let saveTimer=null;
|
||||||
|
let saveDebounce=null;
|
||||||
|
|
||||||
|
async function loadProject(){
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/robot/project',{headers:authH()});
|
||||||
|
if(r.ok){
|
||||||
|
const d=await r.json();
|
||||||
|
parts=Array.isArray(d.parts)?d.parts:[];
|
||||||
|
phases=Array.isArray(d.phases)?d.phases:[];
|
||||||
|
notes=d.notes||'';
|
||||||
|
}
|
||||||
|
}catch{}
|
||||||
|
renderParts();
|
||||||
|
renderPhases();
|
||||||
|
document.getElementById('notes-textarea').value=notes;
|
||||||
|
updateBudget();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave(){
|
||||||
|
clearTimeout(saveDebounce);
|
||||||
|
saveDebounce=setTimeout(saveProject,600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject(){
|
||||||
|
try{
|
||||||
|
await fetch('/api/robot/project',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({parts,phases,notes})});
|
||||||
|
showSaveIndicator('저장됨');
|
||||||
|
}catch{showSaveIndicator('저장 실패');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSaveIndicator(msg){
|
||||||
|
const el=document.getElementById('save-indicator');
|
||||||
|
if(!el)return;
|
||||||
|
el.textContent=msg;
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer=setTimeout(()=>{el.textContent='';},2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
function switchTab(tab){
|
||||||
|
document.querySelectorAll('.rb-tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
|
||||||
|
document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active'));
|
||||||
|
document.getElementById('pane-'+tab).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 부품 ──────────────────────────────────────────────────────────────────────
|
||||||
|
function fmtWon(n){return (n||0).toLocaleString('ko-KR')+'원';}
|
||||||
|
|
||||||
|
function renderParts(){
|
||||||
|
const tbody=document.getElementById('parts-tbody');
|
||||||
|
tbody.innerHTML=parts.map(p=>`
|
||||||
|
<tr data-id="${escAttr(p.id)}">
|
||||||
|
<td><input type="text" value="${escAttr(p.name)}" oninput="updatePart('${p.id}','name',this.value)"></td>
|
||||||
|
<td class="rb-col-qty"><input type="number" min="0" value="${p.qty}" oninput="updatePart('${p.id}','qty',this.value)"></td>
|
||||||
|
<td class="rb-col-price"><input type="number" min="0" value="${p.unitPrice}" oninput="updatePart('${p.id}','unitPrice',this.value)"></td>
|
||||||
|
<td class="rb-col-status">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></td>
|
||||||
|
<td class="rb-col-sub">${fmtWon(p.qty*p.unitPrice)}</td>
|
||||||
|
<td class="rb-col-del"><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
`).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;
|
||||||
|
if(field==='qty'||field==='unitPrice'){
|
||||||
|
const row=document.querySelector(`tr[data-id="${id}"] .rb-col-sub`);
|
||||||
|
if(row)row.textContent=fmtWon(p.qty*p.unitPrice);
|
||||||
|
}
|
||||||
|
updateBudget();
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPart(){
|
||||||
|
parts.push({id:genId('p'),name:'',qty:1,unitPrice:0,status:'검토중',memo:''});
|
||||||
|
renderParts();
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePart(id){
|
||||||
|
parts=parts.filter(x=>x.id!==id);
|
||||||
|
renderParts();
|
||||||
|
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('보유');
|
||||||
|
document.getElementById('sum-review').textContent=fmtWon(review);
|
||||||
|
document.getElementById('sum-ordered').textContent=fmtWon(ordered);
|
||||||
|
document.getElementById('sum-owned').textContent=fmtWon(owned);
|
||||||
|
document.getElementById('sum-total').textContent=fmtWon(review+ordered+owned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 작업(단계) ────────────────────────────────────────────────────────────────
|
||||||
|
function renderPhases(){
|
||||||
|
const container=document.getElementById('phases-container');
|
||||||
|
container.innerHTML=phases.map(ph=>`
|
||||||
|
<div class="rb-phase" data-id="${escAttr(ph.id)}">
|
||||||
|
<div class="rb-phase-hdr">
|
||||||
|
<input class="rb-phase-name" value="${escAttr(ph.name)}" oninput="updatePhaseName('${ph.id}',this.value)">
|
||||||
|
<button class="rb-del-btn" onclick="deletePhase('${ph.id}')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="rb-phase-tasks">
|
||||||
|
${ph.tasks.map(t=>`
|
||||||
|
<div class="rb-task-row" data-id="${escAttr(t.id)}">
|
||||||
|
<input type="checkbox" ${t.done?'checked':''} onchange="updateTask('${ph.id}','${t.id}','done',this.checked)">
|
||||||
|
<input type="text" class="${t.done?'done':''}" value="${escAttr(t.text)}" oninput="updateTask('${ph.id}','${t.id}','text',this.value)">
|
||||||
|
<button class="rb-del-btn" onclick="deleteTask('${ph.id}','${t.id}')">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
<button class="rb-task-add" onclick="addTask('${ph.id}')">+ 작업 추가</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePhaseName(phaseId,value){
|
||||||
|
const ph=phases.find(x=>x.id===phaseId);
|
||||||
|
if(ph){ph.name=value;scheduleSave();}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhase(){
|
||||||
|
phases.push({id:genId('ph'),name:'새 단계',tasks:[]});
|
||||||
|
renderPhases();
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePhase(phaseId){
|
||||||
|
phases=phases.filter(x=>x.id!==phaseId);
|
||||||
|
renderPhases();
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTask(phaseId,taskId,field,value){
|
||||||
|
const ph=phases.find(x=>x.id===phaseId);
|
||||||
|
if(!ph)return;
|
||||||
|
const t=ph.tasks.find(x=>x.id===taskId);
|
||||||
|
if(!t)return;
|
||||||
|
t[field]=value;
|
||||||
|
if(field==='done'){
|
||||||
|
const input=document.querySelector(`.rb-phase[data-id="${phaseId}"] .rb-task-row[data-id="${taskId}"] input[type=text]`);
|
||||||
|
if(input)input.classList.toggle('done',value);
|
||||||
|
}
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTask(phaseId){
|
||||||
|
const ph=phases.find(x=>x.id===phaseId);
|
||||||
|
if(!ph)return;
|
||||||
|
ph.tasks.push({id:genId('t'),text:'',done:false});
|
||||||
|
renderPhases();
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTask(phaseId,taskId){
|
||||||
|
const ph=phases.find(x=>x.id===phaseId);
|
||||||
|
if(!ph)return;
|
||||||
|
ph.tasks=ph.tasks.filter(x=>x.id!==taskId);
|
||||||
|
renderPhases();
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메모 ──────────────────────────────────────────────────────────────────────
|
||||||
|
function onNotesChange(){
|
||||||
|
notes=document.getElementById('notes-textarea').value;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
(async function init(){
|
||||||
|
if(!(await checkAuth()))return;
|
||||||
|
await loadProject();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user