feat: 로봇 부품 카드에 참고링크(구매처/대체품/업그레이드/레포 등) 추가

각 부품 카드에 라벨+URL 쌍을 여러 개 붙일 수 있는 "정보" 섹션 추가 —
파는 곳, 대체품, 업그레이드 옵션, 관련 GitHub 레포 등 무엇이든 자유롭게
기록. URL을 입력하면 옆에 ↗ 열기 버튼이 자동으로 뜬다(타이핑 중 포커스가
끊기지 않게 그 버튼만 DOM에서 직접 갱신, 전체 리렌더 안 함).

- robot-storage.ts: RobotPart에 links: RobotPartLink[] 필드 추가(기존
  저장된 데이터는 링크 없이 시작, 별도 마이그레이션 불필요)
- robot-project.ts: add_part 기본값에 links:[] 추가, get 응답에 부품별
  링크 목록도 같이 표시
- robot-app.html: 카드 펼침 영역에 라벨/URL 입력 행 + 추가/삭제 버튼

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 18:06:28 +09:00
co-authored by Claude Sonnet 5
parent 68c1ae9b21
commit 076a53b8de
3 changed files with 91 additions and 14 deletions
+20 -13
View File
@@ -7,6 +7,12 @@
import fs from 'fs';
import path from 'path';
export interface RobotPartLink {
id: string;
label: string; // 예: "쿠팡", "대체품", "업그레이드", "GitHub 레포"
url: string;
}
export interface RobotPart {
id: string;
name: string;
@@ -14,6 +20,7 @@ export interface RobotPart {
unitPrice: number;
status: '검토중' | '주문완료' | '보유';
memo: string;
links: RobotPartLink[];
}
export interface RobotTask {
@@ -43,19 +50,19 @@ export function robotProjectPath(workspaceRoot: string): string {
export function defaultRobotSeed(): 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 참고' },
{ id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '', links: [] },
{ id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '', links: [] },
{ id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단', links: [] },
{ id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '', links: [] },
{ id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '', links: [] },
{ id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용', links: [] },
{ id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '', links: [] },
{ id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '', links: [] },
{ id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '', links: [] },
{ id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '', links: [] },
{ id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용', links: [] },
{ id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용', links: [] },
{ id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고', links: [] },
],
phases: [
{
+4
View File
@@ -29,6 +29,9 @@ function formatProject(data: RobotProjectData): string {
lines.push('## 부품');
for (const p of data.parts) {
lines.push(`- [${p.status}] ${p.name} × ${p.qty} = ${fmtWon(p.qty * p.unitPrice)}${p.memo ? ` (${p.memo})` : ''}`);
for (const link of p.links || []) {
lines.push(` - ${link.label}: ${link.url}`);
}
}
lines.push('');
lines.push('## 작업');
@@ -122,6 +125,7 @@ export const robotProjectTool = {
unitPrice: Number.isFinite(Number(args?.unit_price)) ? Math.max(0, Number(args.unit_price)) : 0,
status,
memo: String(args?.memo || ''),
links: [],
};
data.parts.push(part);
saveRobotProject(workspaceRoot, data);
+67 -1
View File
@@ -99,6 +99,16 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.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;}
@@ -452,6 +462,18 @@ function renderParts(){
<div class="rb-part-row">
<div class="rb-part-field grow"><label>메모</label><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></div>
</div>
<div class="rb-part-links">
<label>정보(구매처/대체품/업그레이드/레포 등)</label>
${(p.links||[]).map(link=>`
<div class="rb-link-row" data-link-id="${escAttr(link.id)}">
<input type="text" class="rb-link-label" placeholder="라벨(예: 쿠팡)" value="${escAttr(link.label)}" oninput="updateLink('${p.id}','${link.id}','label',this.value)">
<input type="text" placeholder="URL" value="${escAttr(link.url)}" oninput="updateLink('${p.id}','${link.id}','url',this.value)">
${link.url?`<a class="rb-link-open" href="${escAttr(link.url)}" target="_blank" rel="noopener">↗</a>`:''}
<button class="rb-del-btn" onclick="deleteLink('${p.id}','${link.id}')">✕</button>
</div>
`).join('')}
<button class="rb-link-add" onclick="addLink('${p.id}')">+ 링크 추가</button>
</div>
<div class="rb-part-actions"><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
</div>
</div>
@@ -479,7 +501,7 @@ function updatePart(id,field,value){
function addPart(){
const id=genId('p');
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:''});
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:'',links:[]});
expandedParts.add(id);
renderParts();
scheduleSave();
@@ -493,6 +515,50 @@ function deletePart(id){
scheduleSave();
}
function addLink(partId){
const p=parts.find(x=>x.id===partId);
if(!p)return;
if(!p.links)p.links=[];
p.links.push({id:genId('lk'),label:'',url:''});
renderParts();
scheduleSave();
}
function updateLink(partId,linkId,field,value){
const p=parts.find(x=>x.id===partId);
if(!p||!p.links)return;
const link=p.links.find(x=>x.id===linkId);
if(!link)return;
link[field]=value;
// url이 생기거나 없어지면 ↗ 열기 버튼만 DOM에서 직접 갱신 — 전체를 다시 그리면
// input이 리렌더되면서 타이핑 중 커서/포커스가 끊긴다.
if(field==='url'){
const row=document.querySelector(`.rb-part-card[data-id="${partId}"] .rb-link-row[data-link-id="${linkId}"]`);
if(row){
let a=row.querySelector('.rb-link-open');
if(value){
if(!a){
a=document.createElement('a');
a.className='rb-link-open'; a.target='_blank'; a.rel='noopener'; a.textContent='↗';
row.querySelector('.rb-del-btn').before(a);
}
a.href=value;
}else if(a){
a.remove();
}
}
}
scheduleSave();
}
function deleteLink(partId,linkId){
const p=parts.find(x=>x.id===partId);
if(!p||!p.links)return;
p.links=p.links.filter(x=>x.id!==linkId);
renderParts();
scheduleSave();
}
function updateBudget(){
const sum=status=>parts.filter(p=>p.status===status).reduce((s,p)=>s+p.qty*p.unitPrice,0);
const review=sum('검토중'),ordered=sum('주문완료'),owned=sum('보유');