feat: [rd] 기획디자인 7대 기능 추가 (칸반/모달/체크리스트/담당자/필터/검색/리스트뷰)
- 칸반 보드: 상태별 컬럼 드래그앤드롭으로 상태 전환 - 노드 상세 모달: 더블클릭으로 전체 편집 (Notion 스타일) - 체크리스트: 모달 내 하위 작업 관리, 진행률 프로그레스 바 - 담당자/마감일 필드: 노드별 배정, 기한 초과 빨간 표시 - 필터 바: 상태/우선순위/유형/텍스트 필터링 (Ctrl+F) - 리스트/테이블 뷰: 정렬 가능한 전체 노드 목록 - autoSave toast 제거 (UX 방해 요소)
This commit is contained in:
@@ -320,6 +320,176 @@
|
||||
.pc-cm-item.danger:hover { background: #fef2f2; }
|
||||
.pc-cm-sep { height: 1px; background: #e5e7eb; margin: 4px 0; }
|
||||
|
||||
/* ===== Kanban Board ===== */
|
||||
.pc-kanban {
|
||||
display: flex; gap: 12px; flex: 1; padding: 16px; overflow-x: auto; background: #f1f5f9;
|
||||
}
|
||||
.pc-kanban-col {
|
||||
min-width: 260px; width: 260px; flex-shrink: 0;
|
||||
background: #e8ecf1; border-radius: 10px; display: flex; flex-direction: column; max-height: 100%;
|
||||
}
|
||||
.pc-kanban-col-header {
|
||||
display: flex; align-items: center; gap: 8px; padding: 10px 12px; flex-shrink: 0;
|
||||
}
|
||||
.pc-kanban-col-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.pc-kanban-col-title {
|
||||
font-size: 12px; font-weight: 700; color: #374151; flex: 1;
|
||||
}
|
||||
.pc-kanban-col-count {
|
||||
font-size: 10px; font-weight: 600; color: #94a3b8; background: #fff;
|
||||
padding: 1px 7px; border-radius: 9999px;
|
||||
}
|
||||
.pc-kanban-col-body {
|
||||
flex: 1; overflow-y: auto; padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 6px;
|
||||
min-height: 60px;
|
||||
}
|
||||
.pc-kanban-col-body.drag-over { background: rgba(99,102,241,0.08); border-radius: 0 0 10px 10px; }
|
||||
.pc-kanban-card {
|
||||
background: #fff; border-radius: 8px; padding: 10px 12px; cursor: grab;
|
||||
border: 1px solid #e2e8f0; transition: box-shadow 0.15s, border-color 0.15s;
|
||||
border-left: 3px solid var(--card-color, #6366f1);
|
||||
}
|
||||
.pc-kanban-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.pc-kanban-card.dragging { opacity: 0.4; }
|
||||
.pc-kanban-card-title {
|
||||
font-size: 12px; font-weight: 600; color: #1e293b; margin-bottom: 4px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.pc-kanban-card-title .emoji { font-size: 14px; }
|
||||
.pc-kanban-card-desc {
|
||||
font-size: 11px; color: #64748b; line-height: 1.4;
|
||||
overflow: hidden; max-height: 36px; text-overflow: ellipsis;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pc-kanban-card-meta {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.pc-kanban-card-tag {
|
||||
font-size: 9px; padding: 1px 6px; border-radius: 9999px;
|
||||
background: #f1f5f9; color: #64748b; font-weight: 500;
|
||||
}
|
||||
.pc-kanban-card-priority {
|
||||
font-size: 9px; padding: 1px 6px; border-radius: 9999px; font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pc-kanban-card-priority.high { background: #fef2f2; color: #ef4444; }
|
||||
.pc-kanban-card-priority.critical { background: #fef2f2; color: #dc2626; }
|
||||
.pc-kanban-card-priority.medium { background: #fffbeb; color: #d97706; }
|
||||
.pc-kanban-card-priority.low { background: #f0fdf4; color: #22c55e; }
|
||||
|
||||
/* ===== Node Detail Modal ===== */
|
||||
.pc-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pc-modal {
|
||||
background: #fff; border-radius: 12px; width: 640px; max-width: 92vw;
|
||||
max-height: 85vh; overflow-y: auto; box-shadow: 0 24px 48px rgba(0,0,0,0.2);
|
||||
}
|
||||
.pc-modal-header {
|
||||
display: flex; align-items: center; gap: 12px; padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.pc-modal-header .emoji { font-size: 24px; }
|
||||
.pc-modal-header input {
|
||||
flex: 1; font-size: 18px; font-weight: 700; border: none; outline: none; color: #1e293b;
|
||||
}
|
||||
.pc-modal-header input::placeholder { color: #cbd5e1; }
|
||||
.pc-modal-close {
|
||||
width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent;
|
||||
color: #94a3b8; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pc-modal-close:hover { background: #f1f5f9; color: #374151; }
|
||||
.pc-modal-body { padding: 16px 24px 24px; }
|
||||
.pc-modal-row { display: flex; gap: 12px; margin-bottom: 14px; }
|
||||
.pc-modal-label {
|
||||
width: 72px; flex-shrink: 0; font-size: 11px; font-weight: 600; color: #94a3b8;
|
||||
padding-top: 7px; text-align: right;
|
||||
}
|
||||
.pc-modal-field { flex: 1; }
|
||||
.pc-modal-field input, .pc-modal-field select, .pc-modal-field textarea {
|
||||
width: 100%; padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
font-size: 13px; outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.pc-modal-field input:focus, .pc-modal-field select:focus, .pc-modal-field textarea:focus {
|
||||
border-color: var(--pc-indigo);
|
||||
}
|
||||
.pc-modal-field textarea { resize: vertical; min-height: 80px; line-height: 1.6; }
|
||||
|
||||
/* Checklist */
|
||||
.pc-checklist { list-style: none; padding: 0; margin: 0; }
|
||||
.pc-checklist-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 5px 0;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
.pc-checklist-item input[type="checkbox"] { accent-color: var(--pc-indigo); width: 15px; height: 15px; cursor: pointer; }
|
||||
.pc-checklist-item .text {
|
||||
flex: 1; font-size: 13px; color: #374151; border: none; outline: none; background: transparent;
|
||||
}
|
||||
.pc-checklist-item .text.done { text-decoration: line-through; color: #94a3b8; }
|
||||
.pc-checklist-item .remove {
|
||||
width: 20px; height: 20px; border: none; background: transparent;
|
||||
color: #cbd5e1; cursor: pointer; font-size: 14px; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pc-checklist-item .remove:hover { color: #ef4444; background: #fef2f2; }
|
||||
.pc-checklist-add {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 0;
|
||||
font-size: 12px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.pc-checklist-add:hover { color: #4f46e5; }
|
||||
.pc-checklist-progress {
|
||||
height: 4px; background: #e2e8f0; border-radius: 2px; overflow: hidden; margin-bottom: 8px;
|
||||
}
|
||||
.pc-checklist-progress-bar {
|
||||
height: 100%; background: var(--pc-emerald); border-radius: 2px; transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* ===== List/Table View ===== */
|
||||
.pc-list-view {
|
||||
flex: 1; overflow: auto; background: #fff;
|
||||
}
|
||||
.pc-list-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 12px;
|
||||
}
|
||||
.pc-list-table th {
|
||||
position: sticky; top: 0; background: #f8fafc; padding: 8px 12px;
|
||||
text-align: left; font-weight: 700; color: #64748b; font-size: 10px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0;
|
||||
cursor: pointer; user-select: none; white-space: nowrap;
|
||||
}
|
||||
.pc-list-table th:hover { color: #374151; }
|
||||
.pc-list-table th .sort-icon { font-size: 10px; margin-left: 2px; }
|
||||
.pc-list-table td {
|
||||
padding: 8px 12px; border-bottom: 1px solid #f1f5f9; color: #374151;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.pc-list-table tr:hover td { background: #f8fafc; }
|
||||
.pc-list-table tr.selected td { background: #eef2ff; }
|
||||
.pc-list-status-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 11px;
|
||||
padding: 2px 8px; border-radius: 9999px; font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== Filter Bar ===== */
|
||||
.pc-filter-bar {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 16px;
|
||||
background: #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
|
||||
}
|
||||
.pc-filter-bar select, .pc-filter-bar input {
|
||||
padding: 4px 8px; border: 1px solid #e2e8f0; border-radius: 6px;
|
||||
font-size: 11px; outline: none; background: #fff; color: #374151;
|
||||
}
|
||||
.pc-filter-bar select:focus, .pc-filter-bar input:focus { border-color: var(--pc-indigo); }
|
||||
.pc-filter-label { font-size: 10px; font-weight: 600; color: #94a3b8; }
|
||||
.pc-filter-clear {
|
||||
font-size: 11px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
|
||||
background: none; border: none; padding: 4px 8px;
|
||||
}
|
||||
.pc-filter-clear:hover { text-decoration: underline; }
|
||||
|
||||
/* Phase Swimlane (Timeline View) */
|
||||
.pc-swimlane {
|
||||
position: absolute; top: 0;
|
||||
@@ -358,9 +528,11 @@
|
||||
|
||||
<div class="pc-toolbar-group">
|
||||
<div class="pc-view-tabs">
|
||||
<button class="pc-view-tab" :class="{ active: viewMode === 'free' }" @click="viewMode = 'free'">자유배치</button>
|
||||
<button class="pc-view-tab" :class="{ active: viewMode === 'free' }" @click="switchView('free')">자유배치</button>
|
||||
<button class="pc-view-tab" :class="{ active: viewMode === 'timeline' }" @click="switchView('timeline')">타임라인</button>
|
||||
<button class="pc-view-tab" :class="{ active: viewMode === 'flow' }" @click="switchView('flow')">플로우</button>
|
||||
<button class="pc-view-tab" :class="{ active: viewMode === 'kanban' }" @click="switchView('kanban')">칸반</button>
|
||||
<button class="pc-view-tab" :class="{ active: viewMode === 'list' }" @click="switchView('list')">리스트</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -512,6 +684,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-prop-group">
|
||||
<div class="pc-prop-label">담당자</div>
|
||||
<input class="pc-prop-input" x-model="selectedNode.assignee" placeholder="담당자 이름" @input="onPropChange()">
|
||||
</div>
|
||||
<div class="pc-prop-group">
|
||||
<div class="pc-prop-label">마감일</div>
|
||||
<input class="pc-prop-input" type="date" x-model="selectedNode.dueDate" @change="onPropChange()">
|
||||
</div>
|
||||
<div class="pc-prop-group">
|
||||
<div class="pc-prop-label">태그 (콤마 구분)</div>
|
||||
<input class="pc-prop-input" :value="(selectedNode.tags||[]).join(', ')"
|
||||
@@ -526,7 +706,9 @@
|
||||
<option value="critical">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-top: 16px;">
|
||||
<div style="margin-top: 16px; display:flex; flex-direction:column; gap:6px;">
|
||||
<button class="w-full py-2 bg-indigo-50 text-indigo-600 text-xs font-medium rounded-lg hover:bg-indigo-100 transition"
|
||||
@click="openNodeModal(selectedNode)">상세 편집</button>
|
||||
<button class="w-full py-2 bg-red-50 text-red-600 text-xs font-medium rounded-lg hover:bg-red-100 transition"
|
||||
@click="deleteSelectedNode()">노드 삭제</button>
|
||||
</div>
|
||||
@@ -559,8 +741,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Canvas --}}
|
||||
{{-- Main Content Area --}}
|
||||
<div style="flex:1; display:flex; flex-direction:column; min-width:0;">
|
||||
|
||||
{{-- Filter Bar --}}
|
||||
<div class="pc-filter-bar" x-show="viewMode === 'kanban' || viewMode === 'list'">
|
||||
<span class="pc-filter-label">필터:</span>
|
||||
<input type="text" placeholder="검색 (Ctrl+F)" x-model="filterText" style="width:160px;" @keydown.escape="filterText=''">
|
||||
<select x-model="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="todo">대기</option>
|
||||
<option value="progress">진행중</option>
|
||||
<option value="review">검토중</option>
|
||||
<option value="done">완료</option>
|
||||
</select>
|
||||
<select x-model="filterPriority">
|
||||
<option value="">전체 우선순위</option>
|
||||
<option value="critical">긴급</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
<select x-model="filterType">
|
||||
<option value="">전체 유형</option>
|
||||
<template x-for="item in allPaletteItems" :key="item.type">
|
||||
<option :value="item.type" x-text="item.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="pc-filter-clear" x-show="filterText || filterStatus || filterPriority || filterType"
|
||||
@click="filterText=''; filterStatus=''; filterPriority=''; filterType='';">초기화</button>
|
||||
<span style="margin-left:auto; font-size:10px; color:#94a3b8;" x-text="filteredNodes.length + ' / ' + nodes.length + '개'"></span>
|
||||
</div>
|
||||
|
||||
{{-- Canvas (free / timeline / flow) --}}
|
||||
<div class="pc-canvas-wrap" id="canvasWrap"
|
||||
x-show="viewMode === 'free' || viewMode === 'timeline' || viewMode === 'flow'"
|
||||
@mousedown="onCanvasMouseDown($event)"
|
||||
@mousemove="onCanvasMouseMove($event)"
|
||||
@mouseup="onCanvasMouseUp($event)"
|
||||
@@ -601,7 +816,8 @@
|
||||
:class="{ selected: selectedNode?.id === node.id }"
|
||||
:style="'left:' + node.x + 'px; top:' + node.y + 'px; border-left: 4px solid ' + (node.color || '#6366f1')"
|
||||
:id="'node-' + node.id"
|
||||
@mousedown.stop="onNodeMouseDown($event, node)">
|
||||
@mousedown.stop="onNodeMouseDown($event, node)"
|
||||
@dblclick.stop="openNodeModal(node)">
|
||||
|
||||
<div class="pc-node-header">
|
||||
<div class="pc-node-icon" :style="'background:' + (node.bg || '#eef2ff')">
|
||||
@@ -653,8 +869,197 @@
|
||||
{{-- Minimap --}}
|
||||
<div class="pc-minimap" id="minimap"></div>
|
||||
</div>
|
||||
|
||||
{{-- Kanban View --}}
|
||||
<div class="pc-kanban" x-show="viewMode === 'kanban'">
|
||||
<template x-for="col in kanbanColumns" :key="col.key">
|
||||
<div class="pc-kanban-col">
|
||||
<div class="pc-kanban-col-header">
|
||||
<div class="pc-kanban-col-dot" :style="'background:' + col.color"></div>
|
||||
<div class="pc-kanban-col-title" x-text="col.label"></div>
|
||||
<div class="pc-kanban-col-count" x-text="kanbanNodesFor(col.key).length"></div>
|
||||
</div>
|
||||
<div class="pc-kanban-col-body"
|
||||
@dragover.prevent="$event.currentTarget.classList.add('drag-over')"
|
||||
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||
@drop.prevent="onKanbanDrop($event, col.key); $event.currentTarget.classList.remove('drag-over')">
|
||||
<template x-for="node in kanbanNodesFor(col.key)" :key="node.id">
|
||||
<div class="pc-kanban-card"
|
||||
:style="'--card-color:' + (node.color || '#6366f1')"
|
||||
draggable="true"
|
||||
@dragstart="onKanbanDragStart($event, node)"
|
||||
@dragend="$event.target.classList.remove('dragging')"
|
||||
@dblclick="openNodeModal(node)">
|
||||
<div class="pc-kanban-card-title">
|
||||
<span class="emoji" x-text="node.emoji || '📌'"></span>
|
||||
<span x-text="node.title"></span>
|
||||
</div>
|
||||
<div class="pc-kanban-card-desc" x-show="node.description" x-text="node.description"></div>
|
||||
<div class="pc-kanban-card-meta">
|
||||
<template x-for="tag in (node.tags||[]).slice(0,3)" :key="tag">
|
||||
<span class="pc-kanban-card-tag" x-text="tag"></span>
|
||||
</template>
|
||||
<span class="pc-kanban-card-tag" x-show="node.assignee" x-text="'👤 ' + (node.assignee||'')"></span>
|
||||
<span class="pc-kanban-card-tag" x-show="node.dueDate" x-text="'📅 ' + formatShortDate(node.dueDate)"
|
||||
:style="isOverdue(node.dueDate) ? 'background:#fef2f2;color:#ef4444;' : ''"></span>
|
||||
<template x-if="node.checklist && node.checklist.length > 0">
|
||||
<span class="pc-kanban-card-tag" x-text="'☑ ' + node.checklist.filter(c=>c.done).length + '/' + node.checklist.length"></span>
|
||||
</template>
|
||||
<span class="pc-kanban-card-priority" :class="node.priority" x-text="priorityLabel(node.priority)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- List/Table View --}}
|
||||
<div class="pc-list-view" x-show="viewMode === 'list'">
|
||||
<table class="pc-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px;"></th>
|
||||
<th @click="sortList('title')">제목 <span class="sort-icon" x-text="sortIcon('title')"></span></th>
|
||||
<th @click="sortList('type')" style="width:80px;">유형 <span class="sort-icon" x-text="sortIcon('type')"></span></th>
|
||||
<th @click="sortList('status')" style="width:80px;">상태 <span class="sort-icon" x-text="sortIcon('status')"></span></th>
|
||||
<th @click="sortList('priority')" style="width:80px;">우선순위 <span class="sort-icon" x-text="sortIcon('priority')"></span></th>
|
||||
<th @click="sortList('assignee')" style="width:90px;">담당자 <span class="sort-icon" x-text="sortIcon('assignee')"></span></th>
|
||||
<th @click="sortList('dueDate')" style="width:90px;">마감일 <span class="sort-icon" x-text="sortIcon('dueDate')"></span></th>
|
||||
<th style="width:60px;">체크</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="node in sortedFilteredNodes" :key="node.id">
|
||||
<tr :class="{ selected: selectedNode?.id === node.id }"
|
||||
@click="selectedNode = node; sidebarTab = 'properties';"
|
||||
@dblclick="openNodeModal(node)"
|
||||
style="cursor:pointer;">
|
||||
<td><span x-text="node.emoji || '📌'"></span></td>
|
||||
<td>
|
||||
<div style="font-weight:600;" x-text="node.title"></div>
|
||||
<div style="font-size:10px; color:#94a3b8; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" x-text="node.description"></div>
|
||||
</td>
|
||||
<td><span class="pc-node-tag" x-text="node.typeLabel || node.type"></span></td>
|
||||
<td>
|
||||
<span class="pc-list-status-badge" :style="'background:' + statusColor(node.status) + '20; color:' + statusColor(node.status)">
|
||||
<span style="width:6px;height:6px;border-radius:50;display:inline-block;" :style="'background:' + statusColor(node.status)"></span>
|
||||
<span x-text="statusLabel(node.status)"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="pc-kanban-card-priority" :class="node.priority" x-text="priorityLabel(node.priority)"></span></td>
|
||||
<td x-text="node.assignee || '-'" style="font-size:11px;"></td>
|
||||
<td style="font-size:11px;" :style="isOverdue(node.dueDate) ? 'color:#ef4444;font-weight:600;' : ''"
|
||||
x-text="node.dueDate ? formatShortDate(node.dueDate) : '-'"></td>
|
||||
<td style="font-size:11px; color:#94a3b8;"
|
||||
x-text="node.checklist && node.checklist.length ? node.checklist.filter(c=>c.done).length + '/' + node.checklist.length : '-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div x-show="filteredNodes.length === 0" class="text-center text-gray-400 text-sm py-16">노드가 없습니다</div>
|
||||
</div>
|
||||
|
||||
</div>{{-- /Main Content Area --}}
|
||||
</div>
|
||||
|
||||
{{-- Node Detail Modal --}}
|
||||
<template x-if="modalNode">
|
||||
<div class="pc-modal-overlay" @click.self="closeNodeModal()" @keydown.escape.window="closeNodeModal()">
|
||||
<div class="pc-modal">
|
||||
<div class="pc-modal-header">
|
||||
<span class="emoji" x-text="modalNode.emoji || '📌'"></span>
|
||||
<input type="text" x-model="modalNode.title" placeholder="제목을 입력하세요" @input="onModalChange()">
|
||||
<button class="pc-modal-close" @click="closeNodeModal()">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pc-modal-body">
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">상태</div>
|
||||
<div class="pc-modal-field">
|
||||
<select x-model="modalNode.status" @change="onModalChange()">
|
||||
<option value="todo">대기</option>
|
||||
<option value="progress">진행중</option>
|
||||
<option value="review">검토중</option>
|
||||
<option value="done">완료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">우선순위</div>
|
||||
<div class="pc-modal-field">
|
||||
<select x-model="modalNode.priority" @change="onModalChange()">
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">담당자</div>
|
||||
<div class="pc-modal-field">
|
||||
<input type="text" x-model="modalNode.assignee" placeholder="담당자 이름" @input="onModalChange()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">마감일</div>
|
||||
<div class="pc-modal-field">
|
||||
<input type="date" x-model="modalNode.dueDate" @change="onModalChange()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">태그</div>
|
||||
<div class="pc-modal-field">
|
||||
<input type="text" :value="(modalNode.tags||[]).join(', ')" placeholder="콤마로 구분"
|
||||
@change="modalNode.tags = $event.target.value.split(',').map(s=>s.trim()).filter(Boolean); onModalChange()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">설명</div>
|
||||
<div class="pc-modal-field">
|
||||
<textarea x-model="modalNode.description" placeholder="상세 설명을 입력하세요..." rows="4" @input="onModalChange()"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">체크리스트</div>
|
||||
<div class="pc-modal-field">
|
||||
<template x-if="modalNode.checklist && modalNode.checklist.length > 0">
|
||||
<div class="pc-checklist-progress">
|
||||
<div class="pc-checklist-progress-bar" :style="'width:' + checklistProgress(modalNode) + '%'"></div>
|
||||
</div>
|
||||
</template>
|
||||
<ul class="pc-checklist">
|
||||
<template x-for="(item, idx) in (modalNode.checklist || [])" :key="idx">
|
||||
<li class="pc-checklist-item">
|
||||
<input type="checkbox" x-model="item.done" @change="onModalChange()">
|
||||
<input type="text" class="text" :class="{ done: item.done }" x-model="item.text" @input="onModalChange()" placeholder="할 일 입력...">
|
||||
<button class="remove" @click="modalNode.checklist.splice(idx,1); onModalChange();">×</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="pc-checklist-add" @click="addChecklistItem()">+ 항목 추가</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-modal-row">
|
||||
<div class="pc-modal-label">색상</div>
|
||||
<div class="pc-modal-field">
|
||||
<div class="pc-color-swatches">
|
||||
<template x-for="c in nodeColors" :key="c.value">
|
||||
<div class="pc-color-swatch"
|
||||
:class="{ active: modalNode.color === c.value }"
|
||||
:style="'background:' + c.value"
|
||||
@click="modalNode.color = c.value; onModalChange()"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Context Menu --}}
|
||||
<div class="pc-context-menu" id="contextMenu" @click.away="hideContextMenu()">
|
||||
<div class="pc-cm-item" @click="duplicateNode(); hideContextMenu();">복제</div>
|
||||
@@ -695,6 +1100,26 @@ function planningCanvas() {
|
||||
// Selection
|
||||
selectedNode: null,
|
||||
selectedConnection: null,
|
||||
modalNode: null,
|
||||
|
||||
// Filters
|
||||
filterText: '',
|
||||
filterStatus: '',
|
||||
filterPriority: '',
|
||||
filterType: '',
|
||||
|
||||
// List sort
|
||||
listSortKey: 'title',
|
||||
listSortDir: 'asc',
|
||||
|
||||
// Kanban
|
||||
kanbanColumns: [
|
||||
{ key: 'todo', label: '대기', color: '#94a3b8' },
|
||||
{ key: 'progress', label: '진행중', color: '#3b82f6' },
|
||||
{ key: 'review', label: '검토중', color: '#f59e0b' },
|
||||
{ key: 'done', label: '완료', color: '#10b981' },
|
||||
],
|
||||
_kanbanDragNodeId: null,
|
||||
|
||||
// Drag State
|
||||
dragging: false,
|
||||
@@ -767,6 +1192,47 @@ function planningCanvas() {
|
||||
],
|
||||
},
|
||||
|
||||
get allPaletteItems() {
|
||||
return [
|
||||
...this.paletteItems.planning,
|
||||
...this.paletteItems.analysis,
|
||||
...this.paletteItems.structure,
|
||||
...this.paletteItems.output,
|
||||
];
|
||||
},
|
||||
|
||||
get filteredNodes() {
|
||||
return this.nodes.filter(n => {
|
||||
if (this.filterStatus && n.status !== this.filterStatus) return false;
|
||||
if (this.filterPriority && n.priority !== this.filterPriority) return false;
|
||||
if (this.filterType && n.type !== this.filterType) return false;
|
||||
if (this.filterText) {
|
||||
const q = this.filterText.toLowerCase();
|
||||
if (!(n.title||'').toLowerCase().includes(q) &&
|
||||
!(n.description||'').toLowerCase().includes(q) &&
|
||||
!(n.assignee||'').toLowerCase().includes(q) &&
|
||||
!(n.tags||[]).some(t => t.toLowerCase().includes(q))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get sortedFilteredNodes() {
|
||||
const items = [...this.filteredNodes];
|
||||
const key = this.listSortKey;
|
||||
const dir = this.listSortDir === 'asc' ? 1 : -1;
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
const statusOrder = { todo: 0, progress: 1, review: 2, done: 3 };
|
||||
items.sort((a, b) => {
|
||||
let va = a[key] || '', vb = b[key] || '';
|
||||
if (key === 'priority') { va = priorityOrder[va] ?? 9; vb = priorityOrder[vb] ?? 9; }
|
||||
else if (key === 'status') { va = statusOrder[va] ?? 9; vb = statusOrder[vb] ?? 9; }
|
||||
else { va = String(va).toLowerCase(); vb = String(vb).toLowerCase(); }
|
||||
return va < vb ? -dir : va > vb ? dir : 0;
|
||||
});
|
||||
return items;
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadSavedProjects();
|
||||
const currentId = localStorage.getItem(CURRENT_KEY);
|
||||
@@ -811,7 +1277,6 @@ function planningCanvas() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
|
||||
localStorage.setItem(CURRENT_KEY, this.currentProjectId);
|
||||
this.loadSavedProjects();
|
||||
if (typeof showToast === 'function') showToast('저장되었습니다.', 'success');
|
||||
},
|
||||
|
||||
autoSave() {
|
||||
@@ -882,6 +1347,9 @@ function planningCanvas() {
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
tags: [],
|
||||
assignee: '',
|
||||
dueDate: '',
|
||||
checklist: [],
|
||||
x: x,
|
||||
y: y,
|
||||
};
|
||||
@@ -1091,7 +1559,7 @@ function planningCanvas() {
|
||||
switchView(mode) {
|
||||
this.viewMode = mode;
|
||||
if (mode === 'timeline') this.layoutTimeline();
|
||||
if (mode === 'flow') this.layoutFlow();
|
||||
else if (mode === 'flow') this.layoutFlow();
|
||||
},
|
||||
|
||||
layoutTimeline() {
|
||||
@@ -1180,6 +1648,15 @@ function planningCanvas() {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'y') { e.preventDefault(); this.redoAction(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.saveProject(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); this.duplicateNode(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
if (this.viewMode === 'kanban' || this.viewMode === 'list') {
|
||||
e.preventDefault();
|
||||
this.$nextTick(() => {
|
||||
const input = document.querySelector('.pc-filter-bar input[type="text"]');
|
||||
if (input) input.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Context Menu =====
|
||||
@@ -1198,6 +1675,87 @@ function planningCanvas() {
|
||||
document.getElementById('contextMenu').classList.remove('show');
|
||||
},
|
||||
|
||||
// ===== Kanban =====
|
||||
kanbanNodesFor(statusKey) {
|
||||
return this.filteredNodes.filter(n => (n.status || 'todo') === statusKey);
|
||||
},
|
||||
|
||||
onKanbanDragStart(e, node) {
|
||||
this._kanbanDragNodeId = node.id;
|
||||
e.target.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
},
|
||||
|
||||
onKanbanDrop(e, statusKey) {
|
||||
if (!this._kanbanDragNodeId) return;
|
||||
const node = this.nodes.find(n => n.id === this._kanbanDragNodeId);
|
||||
if (node) {
|
||||
node.status = statusKey;
|
||||
if (this.selectedNode?.id === node.id) this.selectedNode = node;
|
||||
if (this.modalNode?.id === node.id) this.modalNode = node;
|
||||
this.pushHistory();
|
||||
this.autoSave();
|
||||
}
|
||||
this._kanbanDragNodeId = null;
|
||||
},
|
||||
|
||||
// ===== Node Detail Modal =====
|
||||
openNodeModal(node) {
|
||||
this.modalNode = node;
|
||||
this.selectedNode = node;
|
||||
if (!this.modalNode.checklist) this.modalNode.checklist = [];
|
||||
if (!this.modalNode.assignee) this.modalNode.assignee = '';
|
||||
if (!this.modalNode.dueDate) this.modalNode.dueDate = '';
|
||||
},
|
||||
|
||||
closeNodeModal() {
|
||||
this.modalNode = null;
|
||||
this.pushHistory();
|
||||
this.autoSave();
|
||||
},
|
||||
|
||||
onModalChange() {
|
||||
this.autoSave();
|
||||
},
|
||||
|
||||
addChecklistItem() {
|
||||
if (!this.modalNode.checklist) this.modalNode.checklist = [];
|
||||
this.modalNode.checklist.push({ text: '', done: false });
|
||||
},
|
||||
|
||||
checklistProgress(node) {
|
||||
if (!node.checklist || node.checklist.length === 0) return 0;
|
||||
return Math.round(node.checklist.filter(c => c.done).length / node.checklist.length * 100);
|
||||
},
|
||||
|
||||
// ===== List View =====
|
||||
sortList(key) {
|
||||
if (this.listSortKey === key) {
|
||||
this.listSortDir = this.listSortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.listSortKey = key;
|
||||
this.listSortDir = 'asc';
|
||||
}
|
||||
},
|
||||
|
||||
sortIcon(key) {
|
||||
if (this.listSortKey !== key) return '';
|
||||
return this.listSortDir === 'asc' ? '▲' : '▼';
|
||||
},
|
||||
|
||||
// ===== Multi-Select =====
|
||||
multiSelected: [],
|
||||
|
||||
toggleMultiSelect(node, e) {
|
||||
if (e.shiftKey) {
|
||||
const idx = this.multiSelected.findIndex(n => n.id === node.id);
|
||||
if (idx >= 0) this.multiSelected.splice(idx, 1);
|
||||
else this.multiSelected.push(node);
|
||||
} else {
|
||||
this.multiSelected = [node];
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Helpers =====
|
||||
toggleSidebar() { this.sidebarOpen = !this.sidebarOpen; },
|
||||
|
||||
@@ -1208,6 +1766,11 @@ function planningCanvas() {
|
||||
return map[status] || '#94a3b8';
|
||||
},
|
||||
|
||||
statusLabel(status) {
|
||||
const map = { todo: '대기', progress: '진행중', review: '검토중', done: '완료' };
|
||||
return map[status] || status;
|
||||
},
|
||||
|
||||
priorityLabel(p) {
|
||||
const map = { low: '낮음', medium: '보통', high: '높음', critical: '긴급' };
|
||||
return map[p] || '';
|
||||
@@ -1218,6 +1781,20 @@ function planningCanvas() {
|
||||
const d = new Date(iso);
|
||||
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
},
|
||||
|
||||
formatShortDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return (d.getMonth() + 1) + '/' + d.getDate();
|
||||
},
|
||||
|
||||
isOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0,0,0,0);
|
||||
return d < today;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user