Files
sam-manage/resources/views/rd/planning-design/index.blade.php
김보곤 90c03b3f14 feat: [planning-design] 메뉴 트리 편집 모달 UI 추가
- JSON prompt 방식 → 트리구조 모달 UI로 개선
- 상위/하위 메뉴 추가, 삭제, 이름 편집 지원
- 드래그 앤 드롭으로 메뉴 순서 변경 가능
- 접기/펼치기 토글 지원
2026-03-07 23:07:37 +09:00

2533 lines
122 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', '기획디자인')
@section('content')
<style>
/* ===== Planning Canvas Core ===== */
:root {
--pc-sidebar: 280px;
--pc-toolbar: 48px;
--pc-blue: #3b82f6;
--pc-indigo: #6366f1;
--pc-violet: #8b5cf6;
--pc-emerald: #10b981;
--pc-amber: #f59e0b;
--pc-rose: #f43f5e;
--pc-slate: #64748b;
}
.pc-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 64px);
background: #f8fafc;
overflow: hidden;
}
/* Top Toolbar */
.pc-toolbar {
height: var(--pc-toolbar);
background: #1e293b;
display: flex;
align-items: center;
padding: 0 12px;
gap: 4px;
flex-shrink: 0;
z-index: 30;
}
.pc-toolbar-group {
display: flex;
align-items: center;
gap: 2px;
padding: 0 8px;
border-right: 1px solid rgba(255,255,255,0.1);
}
.pc-toolbar-group:last-child { border-right: none; }
.pc-tb-btn {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 6px; border: none; background: transparent;
color: #94a3b8; cursor: pointer; transition: all 0.15s;
font-size: 11px;
}
.pc-tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
.pc-tb-btn.active { background: var(--pc-blue); color: #fff; }
.pc-tb-btn svg { width: 16px; height: 16px; }
.pc-tb-title {
flex: 1; text-align: center;
font-size: 13px; font-weight: 600; color: #e2e8f0;
letter-spacing: -0.3px;
}
.pc-tb-title input {
background: transparent; border: none; color: #e2e8f0;
font-size: 13px; font-weight: 600; text-align: center;
outline: none; width: 300px;
}
.pc-tb-title input:focus { border-bottom: 1px solid var(--pc-blue); }
.pc-tb-badge {
font-size: 10px; padding: 2px 6px; border-radius: 9999px;
background: rgba(99,102,241,0.2); color: #a5b4fc; font-weight: 500;
}
.pc-tb-sep { width: 1px; height: 24px; background: rgba(255,255,255,0.1); margin: 0 4px; }
/* Body: Sidebar + Canvas */
.pc-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Left Sidebar — Node Palette + Properties */
.pc-sidebar {
width: var(--pc-sidebar);
background: #fff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.2s;
}
.pc-sidebar.collapsed { width: 0; border-right: none; }
.pc-sidebar-tabs {
display: flex; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.pc-sidebar-tab {
flex: 1; padding: 8px 0; text-align: center;
font-size: 11px; font-weight: 600; color: #94a3b8;
cursor: pointer; border-bottom: 2px solid transparent;
transition: all 0.15s; background: none; border-top: none; border-left: none; border-right: none;
}
.pc-sidebar-tab.active { color: var(--pc-indigo); border-bottom-color: var(--pc-indigo); }
.pc-sidebar-panel {
flex: 1; overflow-y: auto; padding: 12px;
display: none;
}
.pc-sidebar-panel.active { display: block; }
/* Node Palette Items */
.pc-palette-section { margin-bottom: 16px; }
.pc-palette-section h4 {
font-size: 10px; font-weight: 700; text-transform: uppercase;
color: #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px;
}
.pc-palette-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
}
.pc-palette-item {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 6px; border-radius: 8px; border: 1px solid #e2e8f0;
background: #fff; cursor: grab; transition: all 0.15s;
font-size: 10px; color: #64748b; user-select: none;
}
.pc-palette-item:hover { border-color: var(--pc-indigo); background: #f5f3ff; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(99,102,241,0.1); }
.pc-palette-item:active { cursor: grabbing; }
.pc-palette-icon {
width: 36px; height: 36px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
/* Canvas Area */
.pc-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
background:
radial-gradient(circle at 1px 1px, #e2e8f0 1px, transparent 1px);
background-size: 24px 24px;
}
.pc-canvas {
position: absolute;
top: 0; left: 0;
width: 6000px; height: 4000px;
transform-origin: 0 0;
}
/* Canvas Nodes */
.pc-node {
position: absolute;
min-width: 160px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04);
border: 2px solid transparent;
cursor: move;
user-select: none;
transition: box-shadow 0.15s, border-color 0.15s;
z-index: 10;
}
.pc-node:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.pc-node.selected { border-color: var(--pc-indigo); box-shadow: 0 0 0 3px rgba(99,102,241,0.15), 0 4px 16px rgba(0,0,0,0.1); z-index: 20; }
.pc-node-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px 6px;
border-radius: 12px 12px 0 0;
}
.pc-node-icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0;
}
.pc-node-title {
font-size: 12px; font-weight: 700; color: #1e293b;
flex: 1; min-width: 0;
outline: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pc-node-title:focus {
white-space: normal; word-break: break-all;
}
.pc-node-body {
padding: 4px 12px 10px;
font-size: 11px; color: #64748b; line-height: 1.5;
}
.pc-node-body [contenteditable] {
outline: none; min-height: 16px;
}
.pc-node-tags {
display: flex; flex-wrap: wrap; gap: 3px;
padding: 0 12px 8px;
}
.pc-node-tag {
font-size: 9px; padding: 1px 6px; border-radius: 9999px;
background: #f1f5f9; color: #64748b; font-weight: 500;
}
.pc-node-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; border-top: 1px solid #f1f5f9;
font-size: 10px; color: #94a3b8;
}
/* Connection Ports */
.pc-port {
position: absolute; width: 10px; height: 10px;
background: #fff; border: 2px solid #cbd5e1; border-radius: 50%;
cursor: crosshair; z-index: 15; transition: all 0.15s;
}
.pc-port:hover { border-color: var(--pc-indigo); background: var(--pc-indigo); transform: scale(1.3); }
.pc-port-top { top: -5px; left: 50%; margin-left: -5px; }
.pc-port-bottom { bottom: -5px; left: 50%; margin-left: -5px; }
.pc-port-left { left: -5px; top: 50%; margin-top: -5px; }
.pc-port-right { right: -5px; top: 50%; margin-top: -5px; }
/* SVG Connections */
.pc-connections {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none; z-index: 5;
}
.pc-conn { stroke: #94a3b8; stroke-width: 2; fill: none; }
.pc-conn:hover { stroke: var(--pc-indigo); stroke-width: 3; }
.pc-conn-active { stroke: var(--pc-indigo); stroke-width: 2; stroke-dasharray: 6 3; }
/* Minimap */
.pc-minimap {
position: absolute; bottom: 12px; right: 12px;
width: 180px; height: 120px;
background: rgba(255,255,255,0.95); border: 1px solid #e2e8f0;
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
z-index: 25; overflow: hidden;
}
.pc-minimap-viewport {
position: absolute; border: 2px solid var(--pc-blue);
background: rgba(59,130,246,0.05); border-radius: 2px;
}
/* Zoom Controls */
.pc-zoom-controls {
position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 4px; z-index: 25;
}
.pc-zoom-btn {
width: 32px; height: 32px; border-radius: 8px;
background: #fff; border: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #64748b; font-size: 14px; font-weight: 700;
transition: all 0.15s;
}
.pc-zoom-btn:hover { background: #f1f5f9; color: #1e293b; }
.pc-zoom-label {
height: 32px; padding: 0 10px; border-radius: 8px;
background: #fff; border: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #64748b;
}
/* Properties Panel */
.pc-props-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; color: #94a3b8;
font-size: 12px; gap: 8px;
}
.pc-prop-group { margin-bottom: 12px; }
.pc-prop-label {
font-size: 10px; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 4px;
}
.pc-prop-input {
width: 100%; padding: 6px 8px; border: 1px solid #e2e8f0;
border-radius: 6px; font-size: 12px; outline: none;
transition: border-color 0.15s;
}
.pc-prop-input:focus { border-color: var(--pc-indigo); }
.pc-prop-textarea {
width: 100%; padding: 6px 8px; border: 1px solid #e2e8f0;
border-radius: 6px; font-size: 12px; outline: none;
resize: vertical; min-height: 60px;
}
.pc-color-swatches {
display: flex; gap: 4px; flex-wrap: wrap;
}
.pc-color-swatch {
width: 24px; height: 24px; border-radius: 6px;
cursor: pointer; border: 2px solid transparent;
transition: all 0.15s;
}
.pc-color-swatch.active { border-color: #1e293b; transform: scale(1.15); }
.pc-color-swatch:hover { transform: scale(1.1); }
/* View Mode Tabs */
.pc-view-tabs {
display: flex; gap: 2px;
}
.pc-view-tab {
padding: 4px 10px; border-radius: 6px;
font-size: 11px; font-weight: 600; color: #94a3b8;
cursor: pointer; border: none; background: transparent;
transition: all 0.15s;
}
.pc-view-tab.active { background: rgba(255,255,255,0.15); color: #fff; }
.pc-view-tab:hover:not(.active) { color: #e2e8f0; }
/* Context Menu */
.pc-context-menu {
position: fixed; z-index: 100;
background: #fff; border: 1px solid #e2e8f0;
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 4px; min-width: 160px; display: none;
}
.pc-context-menu.show { display: block; }
.pc-cm-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: 6px;
font-size: 12px; color: #374151; cursor: pointer;
transition: background 0.1s;
}
.pc-cm-item:hover { background: #f3f4f6; }
.pc-cm-item.danger { color: #ef4444; }
.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; }
/* ===== Storyboard View ===== */
.sb-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #e5e7eb; }
.sb-topbar {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
background: #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.sb-topbar label { font-size: 10px; font-weight: 600; color: #94a3b8; }
.sb-topbar input, .sb-topbar select {
padding: 4px 8px; border: 1px solid #e2e8f0; border-radius: 6px;
font-size: 12px; outline: none;
}
.sb-topbar input:focus { border-color: var(--pc-indigo); }
.sb-topbar-sep { width: 1px; height: 24px; background: #e2e8f0; }
.sb-pages-nav {
display: flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 600; color: #374151;
}
.sb-pages-nav button {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #e2e8f0;
background: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #64748b;
}
.sb-pages-nav button:hover { background: #f1f5f9; }
.sb-body { flex: 1; display: flex; overflow: hidden; }
.sb-page-list {
width: 140px; flex-shrink: 0; background: #f1f5f9; border-right: 1px solid #e2e8f0;
overflow-y: auto; padding: 8px;
}
.sb-page-thumb {
padding: 6px 8px; border-radius: 6px; cursor: pointer; margin-bottom: 4px;
font-size: 10px; color: #64748b; border: 2px solid transparent;
background: #fff; transition: all 0.15s;
}
.sb-page-thumb:hover { border-color: #cbd5e1; }
.sb-page-thumb.active { border-color: var(--pc-indigo); background: #eef2ff; color: #4338ca; }
.sb-page-thumb-num { font-weight: 700; font-size: 11px; color: #374151; }
.sb-editor { flex: 1; overflow: auto; padding: 24px; display: flex; justify-content: center; }
/* Storyboard Page (A4-like) */
.sb-page {
width: 1100px; min-height: 750px; background: #fff; border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1); display: flex; flex-direction: column;
flex-shrink: 0;
}
.sb-page-header {
display: grid; grid-template-columns: 1fr auto auto auto auto auto;
border-bottom: 2px solid #1e293b; font-size: 10px;
}
.sb-page-header > div {
padding: 6px 10px; border-right: 1px solid #cbd5e1;
display: flex; flex-direction: column; justify-content: center;
}
.sb-page-header > div:last-child { border-right: none; }
.sb-page-header .label { font-size: 8px; color: #94a3b8; font-weight: 600; text-transform: uppercase; }
.sb-page-header .value { font-size: 11px; font-weight: 700; color: #1e293b; }
.sb-page-header .value input {
border: none; outline: none; font-size: 11px; font-weight: 700; color: #1e293b;
width: 100%; background: transparent;
}
.sb-page-header .value input:focus { border-bottom: 1px solid var(--pc-indigo); }
.sb-page-body { display: flex; flex: 1; min-height: 0; }
/* Left Menu Panel */
.sb-menu-panel {
width: 160px; flex-shrink: 0; border-right: 1px solid #e2e8f0;
padding: 12px 0; font-size: 11px; overflow-y: auto; background: #f8fafc;
}
.sb-menu-section { font-size: 8px; font-weight: 700; color: #94a3b8; padding: 4px 12px; text-transform: uppercase; }
.sb-menu-item {
padding: 5px 12px 5px 16px; color: #64748b; cursor: default;
font-size: 11px; line-height: 1.4;
}
.sb-menu-item.active { color: #4338ca; font-weight: 700; background: #eef2ff; border-right: 3px solid #4338ca; }
.sb-menu-child { padding-left: 28px; font-size: 10px; }
.sb-menu-child.active { color: #4338ca; font-weight: 700; }
.sb-menu-logo { padding: 8px 12px; font-size: 13px; font-weight: 800; color: #1e293b; letter-spacing: -0.5px; }
/* Main Content + Description */
.sb-content-area { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.sb-wireframe {
flex: 1; padding: 16px; min-height: 300px; position: relative;
}
.sb-wireframe-placeholder {
border: 2px dashed #d1d5db; border-radius: 8px; height: 100%; min-height: 280px;
display: flex; align-items: center; justify-content: center; flex-direction: column;
color: #94a3b8; font-size: 12px; gap: 8px; cursor: pointer; transition: all 0.15s;
}
.sb-wireframe-placeholder:hover { border-color: var(--pc-indigo); background: #fafafe; }
.sb-wireframe-content {
width: 100%; min-height: 280px; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 16px; font-size: 13px; line-height: 1.7; color: #374151;
outline: none; overflow: auto;
}
.sb-wireframe-content:focus { border-color: var(--pc-indigo); }
.sb-wireframe-img { max-width: 100%; border-radius: 8px; }
.sb-desc-panel {
border-top: 2px solid #1e293b; padding: 12px 16px; background: #fafbfc;
max-height: 260px; overflow-y: auto;
}
.sb-desc-title {
font-size: 10px; font-weight: 700; color: #1e293b; margin-bottom: 8px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.sb-desc-item {
display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px solid #f1f5f9;
font-size: 12px; line-height: 1.6;
}
.sb-desc-item:last-child { border-bottom: none; }
.sb-desc-num {
width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
background: #1e293b; color: #fff; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.sb-desc-text { flex: 1; color: #374151; }
.sb-desc-text textarea {
width: 100%; border: none; outline: none; font-size: 12px; line-height: 1.6;
color: #374151; resize: none; background: transparent; min-height: 20px;
}
.sb-desc-text textarea:focus { background: #fff; border-radius: 4px; }
.sb-desc-add {
display: flex; align-items: center; gap: 6px; padding: 8px 0;
font-size: 11px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
}
.sb-desc-add:hover { color: #4f46e5; }
.sb-desc-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; flex-shrink: 0;
}
.sb-desc-remove:hover { color: #ef4444; background: #fef2f2; }
/* Menu Tree Editor Modal */
.sb-menu-modal-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
}
.sb-menu-modal {
background: #fff; border-radius: 12px; width: 480px; max-height: 80vh;
display: flex; flex-direction: column; box-shadow: 0 25px 50px rgba(0,0,0,0.15);
}
.sb-menu-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #e2e8f0;
}
.sb-menu-modal-header h3 { font-size: 15px; font-weight: 700; color: #1e293b; margin: 0; }
.sb-menu-modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.sb-menu-modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid #e2e8f0;
}
.sb-mt-item {
display: flex; align-items: center; gap: 6px; padding: 5px 0;
border-bottom: 1px solid #f8fafc;
}
.sb-mt-item:hover { background: #f8fafc; border-radius: 6px; }
.sb-mt-drag {
cursor: grab; color: #cbd5e1; flex-shrink: 0; font-size: 14px; padding: 0 2px;
user-select: none;
}
.sb-mt-drag:active { cursor: grabbing; }
.sb-mt-icon { flex-shrink: 0; width: 18px; text-align: center; color: #94a3b8; font-size: 11px; cursor: pointer; }
.sb-mt-icon:hover { color: #475569; }
.sb-mt-name {
flex: 1; border: none; outline: none; font-size: 13px; padding: 4px 6px;
border-radius: 4px; color: #334155; background: transparent;
}
.sb-mt-name:focus { background: #f1f5f9; }
.sb-mt-actions { display: flex; gap: 2px; flex-shrink: 0; }
.sb-mt-btn {
width: 24px; height: 24px; border: none; background: transparent;
color: #94a3b8; cursor: pointer; border-radius: 4px; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.sb-mt-btn:hover { background: #f1f5f9; color: #475569; }
.sb-mt-btn.danger:hover { background: #fef2f2; color: #ef4444; }
.sb-mt-children { padding-left: 24px; border-left: 2px solid #e2e8f0; margin-left: 10px; }
.sb-mt-add-root {
display: flex; align-items: center; gap: 6px; padding: 10px 0 4px;
font-size: 12px; color: var(--pc-indigo); cursor: pointer; font-weight: 500;
}
.sb-mt-add-root:hover { color: #4f46e5; }
/* Phase Swimlane (Timeline View) */
.pc-swimlane {
position: absolute; top: 0;
height: 100%;
border-left: 2px dashed rgba(99,102,241,0.2);
padding: 8px 12px;
pointer-events: none;
}
.pc-swimlane-label {
font-size: 10px; font-weight: 700; color: var(--pc-indigo);
text-transform: uppercase; letter-spacing: 1px; opacity: 0.5;
}
</style>
<div class="pc-wrap" id="planningCanvas" x-data="planningCanvas()" x-init="init()" @keydown.window="handleKeyDown($event)" @keyup.window="handleKeyUp($event)">
{{-- ===== Top Toolbar ===== --}}
<div class="pc-toolbar">
{{-- Left: Tools --}}
<div class="pc-toolbar-group">
<button class="pc-tb-btn" :class="{ active: tool === 'select' }" @click="tool = 'select'" title="선택 (V)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/></svg>
</button>
<button class="pc-tb-btn" :class="{ active: tool === 'pan' }" @click="tool = 'pan'" title="이동 (H)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"/></svg>
</button>
<button class="pc-tb-btn" :class="{ active: tool === 'connect' }" @click="tool = 'connect'" title="연결 (C)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</button>
</div>
{{-- Center: Title + View Mode --}}
<div class="pc-tb-title">
<input type="text" x-model="projectTitle" placeholder="프로젝트 이름을 입력하세요" spellcheck="false">
</div>
<div class="pc-toolbar-group">
<div class="pc-view-tabs">
<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>
<button class="pc-view-tab" :class="{ active: viewMode === 'storyboard' }" @click="switchView('storyboard')" style="color:#f59e0b;">스토리보드</button>
</div>
</div>
{{-- Right: Actions --}}
<div class="pc-toolbar-group">
<button class="pc-tb-btn" @click="toggleSidebar()" title="사이드바 토글">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
</button>
<button class="pc-tb-btn" @click="undoAction()" title="실행 취소 (Ctrl+Z)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
</button>
<button class="pc-tb-btn" @click="redoAction()" title="다시 실행 (Ctrl+Y)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6"/></svg>
</button>
<div class="pc-tb-sep"></div>
<button class="pc-tb-btn" @click="exportProject()" title="내보내기">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
<button class="pc-tb-btn" @click="saveProject()" title="저장 (Ctrl+S)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/></svg>
</button>
<span class="pc-tb-badge" x-text="nodes.length + ' nodes'"></span>
</div>
</div>
{{-- ===== Body ===== --}}
<div class="pc-body">
{{-- Left Sidebar --}}
<div class="pc-sidebar" :class="{ collapsed: !sidebarOpen }">
<div class="pc-sidebar-tabs">
<button class="pc-sidebar-tab" :class="{ active: sidebarTab === 'palette' }" @click="sidebarTab = 'palette'">노드</button>
<button class="pc-sidebar-tab" :class="{ active: sidebarTab === 'properties' }" @click="sidebarTab = 'properties'">속성</button>
<button class="pc-sidebar-tab" :class="{ active: sidebarTab === 'projects' }" @click="sidebarTab = 'projects'">프로젝트</button>
</div>
{{-- Palette Panel --}}
<div class="pc-sidebar-panel" :class="{ active: sidebarTab === 'palette' }">
{{-- 기획 단계 --}}
<div class="pc-palette-section">
<h4>기획 단계</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.planning" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
{{-- 분석 도구 --}}
<div class="pc-palette-section">
<h4>분석 & 설계</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.analysis" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
{{-- 구조 요소 --}}
<div class="pc-palette-section">
<h4>구조 & 흐름</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.structure" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
{{-- 산출물 --}}
<div class="pc-palette-section">
<h4>산출물</h4>
<div class="pc-palette-grid">
<template x-for="item in paletteItems.output" :key="item.type">
<div class="pc-palette-item"
draggable="true"
@dragstart="onPaletteDragStart($event, item)"
@dblclick="addNodeAtCenter(item)">
<div class="pc-palette-icon" :style="'background:' + item.bg">
<span x-text="item.emoji"></span>
</div>
<span x-text="item.label"></span>
</div>
</template>
</div>
</div>
</div>
{{-- Properties Panel --}}
<div class="pc-sidebar-panel" :class="{ active: sidebarTab === 'properties' }">
<template x-if="!selectedNode">
<div class="pc-props-empty">
<svg class="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
</svg>
<span>노드를 선택하세요</span>
</div>
</template>
<template x-if="selectedNode">
<div>
<div class="pc-prop-group">
<div class="pc-prop-label">제목</div>
<input class="pc-prop-input" x-model="selectedNode.title" @input="onPropChange()">
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">설명</div>
<textarea class="pc-prop-textarea" x-model="selectedNode.description" @input="onPropChange()"></textarea>
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">상태</div>
<select class="pc-prop-input" x-model="selectedNode.status" @change="onPropChange()">
<option value="todo">대기</option>
<option value="progress">진행중</option>
<option value="review">검토중</option>
<option value="done">완료</option>
</select>
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">색상</div>
<div class="pc-color-swatches">
<template x-for="c in nodeColors" :key="c.value">
<div class="pc-color-swatch"
:class="{ active: selectedNode.color === c.value }"
:style="'background:' + c.value"
@click="selectedNode.color = c.value; onPropChange()"></div>
</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(', ')"
@change="selectedNode.tags = $event.target.value.split(',').map(s=>s.trim()).filter(Boolean); onPropChange()">
</div>
<div class="pc-prop-group">
<div class="pc-prop-label">우선순위</div>
<select class="pc-prop-input" x-model="selectedNode.priority" @change="onPropChange()">
<option value="low">낮음</option>
<option value="medium">보통</option>
<option value="high">높음</option>
<option value="critical">긴급</option>
</select>
</div>
<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>
</div>
</template>
</div>
{{-- Projects Panel --}}
<div class="pc-sidebar-panel" :class="{ active: sidebarTab === 'projects' }">
<div style="margin-bottom: 12px;">
<button class="w-full py-2 bg-indigo-50 text-indigo-600 text-xs font-medium rounded-lg hover:bg-indigo-100 transition"
@click="newProject()">+ 프로젝트</button>
</div>
<template x-for="(proj, idx) in savedProjects" :key="proj.id">
<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 cursor-pointer mb-1"
:class="{ 'bg-indigo-50': currentProjectId === proj.id }"
@click="loadProject(proj.id)">
<div class="flex-1 min-w-0">
<div class="text-xs font-medium text-gray-800 truncate" x-text="proj.title || '제목 없음'"></div>
<div class="text-xs text-gray-400" x-text="proj.nodeCount + '개 노드 · ' + formatDate(proj.updatedAt)"></div>
</div>
<button class="text-gray-300 hover:text-red-500 transition text-xs" @click.stop="deleteProject(proj.id)">
<svg class="w-3.5 h-3.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>
</template>
<div x-show="savedProjects.length === 0" class="text-center text-gray-400 text-xs py-8">
저장된 프로젝트가 없습니다
</div>
</div>
</div>
{{-- 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)"
@wheel="onCanvasWheel($event)"
@dragover.prevent
@drop="onCanvasDrop($event)"
@contextmenu.prevent="showContextMenu($event)">
<div class="pc-canvas" id="canvas" :style="'transform: scale(' + zoom + ') translate(' + panX + 'px,' + panY + 'px)'">
{{-- SVG Connections (프로그래밍 렌더링 Alpine template은 SVG 네임스페이스 미지원) --}}
<svg class="pc-connections" id="connectionsSvg" x-ref="connSvg" x-effect="renderConnections()"></svg>
{{-- Timeline Swimlanes --}}
<template x-if="viewMode === 'timeline'">
<template x-for="(phase, idx) in phases" :key="phase.id">
<div class="pc-swimlane" :style="'left:' + (idx * 400 + 100) + 'px; width: 400px;'">
<div class="pc-swimlane-label" x-text="phase.name"></div>
</div>
</template>
</template>
{{-- Nodes --}}
<template x-for="node in nodes" :key="node.id">
<div class="pc-node"
: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)"
@dblclick.stop="openNodeModal(node)">
<div class="pc-node-header">
<div class="pc-node-icon" :style="'background:' + (node.bg || '#eef2ff')">
<span x-text="node.emoji || '📌'"></span>
</div>
<div class="pc-node-title"
contenteditable="true"
@blur="node.title = $event.target.textContent; autoSave()"
@keydown.enter.prevent="$event.target.blur()"
x-text="node.title"></div>
<div style="width: 8px; height: 8px; border-radius: 50; flex-shrink: 0;"
:style="'background:' + statusColor(node.status)"></div>
</div>
<div class="pc-node-body" x-show="node.description">
<div x-text="node.description" style="overflow:hidden; max-height: 40px; text-overflow: ellipsis;"></div>
</div>
<div class="pc-node-tags" x-show="node.tags && node.tags.length > 0">
<template x-for="tag in (node.tags||[])" :key="tag">
<span class="pc-node-tag" x-text="tag"></span>
</template>
</div>
<div class="pc-node-footer">
<span x-text="node.typeLabel || node.type"></span>
<span x-text="priorityLabel(node.priority)"></span>
</div>
{{-- Connection Ports --}}
<div class="pc-port pc-port-top" @mousedown.stop="startConnection($event, node, 'top')"></div>
<div class="pc-port pc-port-bottom" @mousedown.stop="startConnection($event, node, 'bottom')"></div>
<div class="pc-port pc-port-left" @mousedown.stop="startConnection($event, node, 'left')"></div>
<div class="pc-port pc-port-right" @mousedown.stop="startConnection($event, node, 'right')"></div>
</div>
</template>
</div>
{{-- Zoom Controls --}}
<div class="pc-zoom-controls">
<button class="pc-zoom-btn" @click="zoomIn()">+</button>
<div class="pc-zoom-label" x-text="Math.round(zoom * 100) + '%'"></div>
<button class="pc-zoom-btn" @click="zoomOut()">-</button>
<button class="pc-zoom-btn" @click="zoomFit()" title="맞춤">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/></svg>
</button>
</div>
{{-- 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>
{{-- Storyboard View --}}
<div class="sb-wrap" x-show="viewMode === 'storyboard'">
{{-- Storyboard Top Bar --}}
<div class="sb-topbar">
<label>프로젝트</label>
<input type="text" x-model="sb.docInfo.projectName" placeholder="프로젝트명" style="width:200px;" @input="autoSave()">
<div class="sb-topbar-sep"></div>
<label>단위업무</label>
<input type="text" x-model="sb.docInfo.unitTask" placeholder="단위업무명" style="width:120px;" @input="autoSave()">
<div class="sb-topbar-sep"></div>
<label>버전</label>
<input type="text" x-model="sb.docInfo.version" placeholder="D1.0" style="width:60px;" @input="autoSave()">
<div class="sb-topbar-sep"></div>
<div class="sb-pages-nav">
<button @click="sbPrevPage()" title="이전 페이지">&lt;</button>
<span x-text="(sb.currentPageIndex + 1) + ' / ' + sb.pages.length"></span>
<button @click="sbNextPage()" title="다음 페이지">&gt;</button>
</div>
<div class="sb-topbar-sep"></div>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#374151;"
@click="sbAddPage()">+ 페이지 추가</button>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#ef4444;"
x-show="sb.pages.length > 1"
@click="sbDeletePage()">페이지 삭제</button>
<div style="margin-left:auto;"></div>
<button style="padding:4px 10px; border:1px solid #e2e8f0; border-radius:6px; font-size:11px; cursor:pointer; background:#fff; color:#374151;"
@click="sbEditMenu()">메뉴 편집</button>
<button style="padding:4px 10px; border:1px solid var(--pc-indigo); border-radius:6px; font-size:11px; cursor:pointer; background:var(--pc-indigo); color:#fff;"
@click="sbExportHtml()">HTML 내보내기</button>
</div>
<div class="sb-body">
{{-- Page List (thumbnail) --}}
<div class="sb-page-list">
<template x-for="(pg, idx) in sb.pages" :key="pg.id">
<div class="sb-page-thumb" :class="{ active: sb.currentPageIndex === idx }" @click="sb.currentPageIndex = idx">
<div class="sb-page-thumb-num" x-text="(idx + 1) + '.'"></div>
<div x-text="pg.screenName || '(화면명)'" style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></div>
</div>
</template>
</div>
{{-- Page Editor --}}
<div class="sb-editor">
<template x-if="sbCurrentPage">
<div class="sb-page">
{{-- Page Header --}}
<div class="sb-page-header">
<div>
<span class="label">단위업무명</span>
<span class="value" x-text="sb.docInfo.unitTask || '-'"></span>
</div>
<div>
<span class="label">버전</span>
<span class="value" x-text="sb.docInfo.version || '-'"></span>
</div>
<div>
<span class="label">Page</span>
<span class="value" x-text="sb.currentPageIndex + 1"></span>
</div>
<div>
<span class="label">경로</span>
<span class="value"><input type="text" x-model="sbCurrentPage.path" placeholder="품질관리 > 제품검사관리" @input="autoSave()"></span>
</div>
<div>
<span class="label">화면명</span>
<span class="value"><input type="text" x-model="sbCurrentPage.screenName" placeholder="제품검사 목록" @input="autoSave()"></span>
</div>
<div>
<span class="label">화면 ID</span>
<span class="value"><input type="text" x-model="sbCurrentPage.screenId" placeholder="QM-001" @input="autoSave()"></span>
</div>
</div>
<div class="sb-page-body">
{{-- Left Menu --}}
<div class="sb-menu-panel">
<div class="sb-menu-logo" x-text="sb.docInfo.projectName || 'LOGO'"></div>
<div class="sb-menu-section">ERP 메뉴</div>
<template x-for="menu in sb.menuTree" :key="menu.name">
<div>
<div class="sb-menu-item" :class="{ active: sbCurrentPage.path && sbCurrentPage.path.startsWith(menu.name) }" x-text="menu.name"></div>
<template x-for="child in (menu.children || [])" :key="child.name">
<div class="sb-menu-item sb-menu-child"
:class="{ active: sbCurrentPage.path && sbCurrentPage.path.includes(child.name) }"
x-text="'- ' + child.name"></div>
</template>
</div>
</template>
</div>
{{-- Content + Description --}}
<div class="sb-content-area">
{{-- Wireframe Area --}}
<div class="sb-wireframe">
<template x-if="!sbCurrentPage.wireframeContent && !sbCurrentPage.wireframeImage">
<div class="sb-wireframe-placeholder" @click="$refs.sbImageInput?.click()">
<svg style="width:32px;height:32px;color:#cbd5e1;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span>이미지를 업로드하거나 클릭하여 텍스트 입력</span>
<div style="display:flex; gap:8px; margin-top:8px;">
<button style="padding:4px 12px; border:1px solid var(--pc-indigo); border-radius:6px; font-size:11px; color:var(--pc-indigo); background:#fff; cursor:pointer;"
@click.stop="sbCurrentPage.wireframeContent = ' '">텍스트로 작성</button>
<button style="padding:4px 12px; border:1px solid var(--pc-indigo); border-radius:6px; font-size:11px; color:#fff; background:var(--pc-indigo); cursor:pointer;"
@click.stop="$refs.sbImageInput.click()">이미지 업로드</button>
</div>
<input type="file" accept="image/*" x-ref="sbImageInput" style="display:none;" @change="sbUploadImage($event)">
</div>
</template>
<template x-if="sbCurrentPage.wireframeImage">
<div style="position:relative;">
<img class="sb-wireframe-img" :src="sbCurrentPage.wireframeImage" alt="wireframe">
<button style="position:absolute; top:4px; right:4px; width:24px; height:24px; border-radius:50%; border:none; background:rgba(0,0,0,0.5); color:#fff; cursor:pointer; font-size:12px;"
@click="sbCurrentPage.wireframeImage = ''; autoSave();">&times;</button>
</div>
</template>
<template x-if="sbCurrentPage.wireframeContent && !sbCurrentPage.wireframeImage">
<div>
<div class="sb-wireframe-content" contenteditable="true"
@blur="sbCurrentPage.wireframeContent = $event.target.innerHTML; autoSave();"
x-html="sbCurrentPage.wireframeContent"></div>
<div style="display:flex; gap:4px; margin-top:4px;">
<button style="font-size:10px; color:#94a3b8; cursor:pointer; border:none; background:none;"
@click="$refs.sbImageInput2?.click()">이미지로 교체</button>
<input type="file" accept="image/*" x-ref="sbImageInput2" style="display:none;" @change="sbUploadImage($event)">
</div>
</div>
</template>
</div>
{{-- Description Panel --}}
<div class="sb-desc-panel">
<div class="sb-desc-title">Description</div>
<template x-for="(desc, idx) in (sbCurrentPage.descriptions || [])" :key="idx">
<div class="sb-desc-item">
<div class="sb-desc-num" x-text="String(idx + 1).padStart(2, '0')"></div>
<div class="sb-desc-text">
<textarea x-model="desc.text" rows="2" placeholder="기능 설명을 입력하세요..." @input="autoSave()"></textarea>
</div>
<button class="sb-desc-remove" @click="sbCurrentPage.descriptions.splice(idx, 1); autoSave();">&times;</button>
</div>
</template>
<div class="sb-desc-add" @click="sbAddDescription()">+ Description 항목 추가</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>{{-- /Main Content Area --}}
</div>
{{-- Menu Tree Editor Modal --}}
<template x-if="sbMenuEditorOpen">
<div class="sb-menu-modal-overlay" @click.self="sbMenuEditorOpen = false" @keydown.escape.window="sbMenuEditorOpen = false">
<div class="sb-menu-modal" @click.stop>
<div class="sb-menu-modal-header">
<h3>ERP 메뉴 트리 편집</h3>
<button class="sb-mt-btn" @click="sbMenuEditorOpen = false" style="width:28px;height:28px;font-size:16px;">×</button>
</div>
<div class="sb-menu-modal-body">
<template x-for="(menu, mi) in sbMenuDraft" :key="mi">
<div>
<div class="sb-mt-item" draggable="true"
@dragstart="sbMenuDragStart('root', mi, $event)"
@dragover.prevent="sbMenuDragOver('root', mi, $event)"
@drop="sbMenuDrop('root', mi, $event)"
@dragend="sbMenuDragEnd()">
<span class="sb-mt-drag" title="드래그하여 순서 변경"></span>
<span class="sb-mt-icon" @click="sbMenuToggle(mi)" x-text="(menu.children && menu.children.length) ? (menu._open !== false ? '▾' : '▸') : '·'"></span>
<input type="text" class="sb-mt-name" x-model="menu.name" placeholder="메뉴명 입력" style="font-weight:600;">
<div class="sb-mt-actions">
<button class="sb-mt-btn" @click="sbMenuAddChild(mi)" title="하위 메뉴 추가">+</button>
<button class="sb-mt-btn danger" @click="sbMenuRemove(mi)" title="삭제">×</button>
</div>
</div>
<div class="sb-mt-children" x-show="menu._open !== false">
<template x-for="(child, ci) in (menu.children || [])" :key="ci">
<div class="sb-mt-item" draggable="true"
@dragstart="sbMenuDragStart('child', mi + '-' + ci, $event)"
@dragover.prevent="sbMenuDragOver('child', mi + '-' + ci, $event)"
@drop="sbMenuDrop('child', mi + '-' + ci, $event)"
@dragend="sbMenuDragEnd()">
<span class="sb-mt-drag" title="드래그하여 순서 변경"></span>
<span class="sb-mt-icon" style="color:#cbd5e1;"></span>
<input type="text" class="sb-mt-name" x-model="child.name" placeholder="하위 메뉴명">
<div class="sb-mt-actions">
<button class="sb-mt-btn danger" @click="menu.children.splice(ci, 1)" title="삭제">×</button>
</div>
</div>
</template>
<div class="sb-mt-add-root" style="padding:4px 0 2px; font-size:11px;" @click="sbMenuAddChild(mi)">+ 하위 메뉴 추가</div>
</div>
</div>
</template>
<div class="sb-mt-add-root" @click="sbMenuDraft.push({ name: '', children: [], _open: true })">+ 상위 메뉴 추가</div>
</div>
<div class="sb-menu-modal-footer">
<button style="padding:6px 16px; border:1px solid #e2e8f0; border-radius:6px; font-size:12px; cursor:pointer; background:#fff; color:#64748b;"
@click="sbMenuEditorOpen = false">취소</button>
<button style="padding:6px 16px; border:none; border-radius:6px; font-size:12px; cursor:pointer; background:var(--pc-indigo); color:#fff; font-weight:600;"
@click="sbMenuApply()">적용</button>
</div>
</div>
</div>
</template>
{{-- 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();">&times;</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>
<div class="pc-cm-item" @click="bringToFront(); hideContextMenu();"> 앞으로</div>
<div class="pc-cm-sep"></div>
<div class="pc-cm-item" @click="addNodeAtMouse(paletteItems.planning[0]); hideContextMenu();">빠른 노드 추가</div>
<div class="pc-cm-sep"></div>
<div class="pc-cm-item danger" x-show="selectedConnection" @click="deleteSelectedConnection(); hideContextMenu();">연결선 삭제</div>
<div class="pc-cm-item danger" x-show="selectedNode" @click="deleteSelectedNode(); hideContextMenu();">노드 삭제</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function planningCanvas() {
let idCounter = 0;
const STORAGE_KEY = 'pc_projects';
const CURRENT_KEY = 'pc_current';
return {
// State
projectTitle: '새 기획 프로젝트',
currentProjectId: null,
nodes: [],
connections: [],
savedProjects: [],
// Tools
tool: 'select',
viewMode: 'free',
sidebarOpen: true,
sidebarTab: 'palette',
zoom: 1,
panX: 0,
panY: 0,
// 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,
_connTick: 0,
// Storyboard
sbMenuEditorOpen: false,
sbMenuDraft: [],
_sbMenuDrag: null,
sb: {
docInfo: { projectName: '', unitTask: '', version: 'D1.0' },
menuTree: [
{ name: '대시보드', children: [] },
{ name: '판매관리', children: [] },
{ name: '생산관리', children: [] },
{ name: '출고관리', children: [] },
{ name: '품질관리', children: [
{ name: '제품검사관리' },
{ name: '실적신고관리' },
{ name: '품질인정심사' },
]},
{ name: '자재관리', children: [] },
{ name: '기준정보', children: [] },
],
pages: [],
currentPageIndex: 0,
},
// Drag State
dragging: false,
dragNode: null,
dragOffsetX: 0,
dragOffsetY: 0,
panning: false,
panStartX: 0,
panStartY: 0,
spaceHeld: false,
// Connection Drawing
drawingConnection: false,
connSource: null,
connSourcePort: null,
tempConnectionPath: '',
contextMenuPos: { x: 0, y: 0 },
// Undo/Redo
history: [],
historyIndex: -1,
// Phases for timeline
phases: [
{ id: 'discover', name: '발견 (Discover)' },
{ id: 'define', name: '정의 (Define)' },
{ id: 'design', name: '설계 (Design)' },
{ id: 'develop', name: '개발 (Develop)' },
{ id: 'deliver', name: '검증 (Deliver)' },
],
// Colors
nodeColors: [
{ value: '#6366f1', label: 'Indigo' },
{ value: '#3b82f6', label: 'Blue' },
{ value: '#10b981', label: 'Emerald' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#f43f5e', label: 'Rose' },
{ value: '#8b5cf6', label: 'Violet' },
{ value: '#06b6d4', label: 'Cyan' },
{ value: '#64748b', label: 'Slate' },
],
// Node Palette
paletteItems: {
planning: [
{ type: 'vision', label: '비전/목표', emoji: '🎯', bg: '#eef2ff', color: '#6366f1', typeLabel: '비전' },
{ type: 'persona', label: '페르소나', emoji: '👤', bg: '#fef3c7', color: '#f59e0b', typeLabel: '페르소나' },
{ type: 'problem', label: '문제정의', emoji: '❗', bg: '#fce7f3', color: '#ec4899', typeLabel: '문제' },
{ type: 'hypothesis', label: '가설', emoji: '💡', bg: '#ecfdf5', color: '#10b981', typeLabel: '가설' },
{ type: 'requirement', label: '요구사항', emoji: '📋', bg: '#ede9fe', color: '#8b5cf6', typeLabel: '요구사항' },
{ type: 'constraint', label: '제약조건', emoji: '🔒', bg: '#fef2f2', color: '#ef4444', typeLabel: '제약' },
],
analysis: [
{ type: 'usecase', label: '유스케이스', emoji: '🔄', bg: '#dbeafe', color: '#3b82f6', typeLabel: 'UC' },
{ type: 'userflow', label: '사용자흐름', emoji: '🚶', bg: '#e0f2fe', color: '#0ea5e9', typeLabel: 'Flow' },
{ type: 'datamodel', label: '데이터모델', emoji: '🗃️', bg: '#f0fdf4', color: '#22c55e', typeLabel: 'Data' },
{ type: 'api', label: 'API 설계', emoji: '🔌', bg: '#fefce8', color: '#eab308', typeLabel: 'API' },
],
structure: [
{ type: 'decision', label: '의사결정', emoji: '⚖️', bg: '#fff7ed', color: '#f97316', typeLabel: '결정' },
{ type: 'milestone', label: '마일스톤', emoji: '🏁', bg: '#ede9fe', color: '#7c3aed', typeLabel: '마일스톤' },
{ type: 'phase', label: '단계/Phase', emoji: '📐', bg: '#e0e7ff', color: '#4f46e5', typeLabel: 'Phase' },
{ type: 'note', label: '메모', emoji: '📝', bg: '#f8fafc', color: '#64748b', typeLabel: '메모' },
],
output: [
{ type: 'wireframe', label: '와이어프레임', emoji: '🖼️', bg: '#ecfeff', color: '#06b6d4', typeLabel: 'WF' },
{ type: 'prototype', label: '프로토타입', emoji: '🧪', bg: '#faf5ff', color: '#a855f7', typeLabel: 'Proto' },
{ type: 'document', label: '문서/산출물', emoji: '📄', bg: '#f0f9ff', color: '#0284c7', typeLabel: '문서' },
{ type: 'presentation', label: '발표자료', emoji: '📊', bg: '#fffbeb', color: '#d97706', typeLabel: 'PPT' },
],
},
get sbCurrentPage() {
return this.sb.pages[this.sb.currentPageIndex] || null;
},
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);
if (currentId && this.savedProjects.find(p => p.id === currentId)) {
this.loadProject(currentId);
} else {
this.newProject();
}
this.sbInitPages();
},
// ===== Project Management =====
newProject() {
this.currentProjectId = 'proj_' + Date.now();
this.projectTitle = '새 기획 프로젝트';
this.nodes = [];
this.connections = [];
this.selectedNode = null;
this.selectedConnection = null;
this.history = [];
this.historyIndex = -1;
this.panX = 0;
this.panY = 0;
this.zoom = 1;
// 스토리보드 초기화
this.sb.docInfo = { projectName: '', unitTask: '', version: 'D1.0' };
this.sb.pages = [];
this.sb.currentPageIndex = 0;
this.sbInitPages();
localStorage.setItem(CURRENT_KEY, this.currentProjectId);
this.pushHistory();
},
saveProject() {
const proj = {
id: this.currentProjectId,
title: this.projectTitle,
nodes: JSON.parse(JSON.stringify(this.nodes)),
connections: JSON.parse(JSON.stringify(this.connections)),
sb: JSON.parse(JSON.stringify(this.sb)),
viewMode: this.viewMode,
nodeCount: this.nodes.length,
updatedAt: new Date().toISOString(),
};
let projects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
const idx = projects.findIndex(p => p.id === proj.id);
if (idx >= 0) projects[idx] = proj;
else projects.unshift(proj);
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
localStorage.setItem(CURRENT_KEY, this.currentProjectId);
this.loadSavedProjects();
},
autoSave() {
clearTimeout(this._autoSaveTimer);
this._autoSaveTimer = setTimeout(() => this.saveProject(), 2000);
},
loadProject(id) {
const projects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
const proj = projects.find(p => p.id === id);
if (!proj) return;
this.currentProjectId = proj.id;
this.projectTitle = proj.title;
this.nodes = proj.nodes || [];
this.connections = proj.connections || [];
this.viewMode = proj.viewMode || 'free';
this.selectedNode = null;
this.selectedConnection = null;
this.history = [];
this.historyIndex = -1;
// 스토리보드 데이터 복원
if (proj.sb) {
this.sb = JSON.parse(JSON.stringify(proj.sb));
} else {
this.sb.docInfo = { projectName: '', unitTask: '', version: 'D1.0' };
this.sb.pages = [];
this.sb.currentPageIndex = 0;
}
this.sbInitPages();
localStorage.setItem(CURRENT_KEY, id);
this.pushHistory();
idCounter = Math.max(0, ...this.nodes.map(n => parseInt(n.id?.split('_')[1]) || 0)) + 1;
},
deleteProject(id) {
if (!confirm('이 프로젝트를 삭제하시겠습니까?')) return;
let projects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
projects = projects.filter(p => p.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
this.loadSavedProjects();
if (this.currentProjectId === id) this.newProject();
},
loadSavedProjects() {
this.savedProjects = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
},
exportProject() {
const data = {
title: this.projectTitle,
nodes: this.nodes,
connections: this.connections,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (this.projectTitle || 'planning') + '.json';
a.click();
URL.revokeObjectURL(url);
},
// ===== Node Operations =====
generateId() { return 'n_' + (++idCounter); },
addNode(paletteItem, x, y) {
const node = {
id: this.generateId(),
type: paletteItem.type,
typeLabel: paletteItem.typeLabel || paletteItem.label,
title: paletteItem.label,
description: '',
emoji: paletteItem.emoji,
bg: paletteItem.bg,
color: paletteItem.color || '#6366f1',
status: 'todo',
priority: 'medium',
tags: [],
assignee: '',
dueDate: '',
checklist: [],
x: x,
y: y,
};
this.nodes.push(node);
this.selectedNode = node;
this.sidebarTab = 'properties';
this.pushHistory();
this.autoSave();
return node;
},
addNodeAtCenter(paletteItem) {
const wrap = document.getElementById('canvasWrap');
const cx = (wrap.clientWidth / 2 - this.panX * this.zoom) / this.zoom;
const cy = (wrap.clientHeight / 2 - this.panY * this.zoom) / this.zoom;
this.addNode(paletteItem, cx - 80, cy - 40);
},
addNodeAtMouse(paletteItem) {
const x = (this.contextMenuPos.x - this.panX * this.zoom) / this.zoom;
const y = (this.contextMenuPos.y - this.panY * this.zoom) / this.zoom;
this.addNode(paletteItem, x, y);
},
deleteSelectedNode() {
if (!this.selectedNode) return;
const id = this.selectedNode.id;
this.connections = this.connections.filter(c => c.from !== id && c.to !== id);
this.nodes = this.nodes.filter(n => n.id !== id);
this.selectedNode = null;
this.pushHistory();
this.autoSave();
},
duplicateNode() {
if (!this.selectedNode) return;
const orig = this.selectedNode;
const dup = { ...JSON.parse(JSON.stringify(orig)), id: this.generateId(), x: orig.x + 30, y: orig.y + 30 };
this.nodes.push(dup);
this.selectedNode = dup;
this.pushHistory();
this.autoSave();
},
bringToFront() {
if (!this.selectedNode) return;
const idx = this.nodes.findIndex(n => n.id === this.selectedNode.id);
if (idx >= 0) {
const [node] = this.nodes.splice(idx, 1);
this.nodes.push(node);
}
},
// ===== Connection Rendering (SVG namespace 직접 제어) =====
renderConnections() {
// _connTick 읽기 → x-effect 의존성 등록 (노드 이동 시 리렌더 트리거)
void this._connTick;
void this.connections.length;
void this.selectedConnection;
void this.drawingConnection;
void this.tempConnectionPath;
const svg = this.$refs.connSvg;
if (!svg) return;
// 현재 내용 클리어
svg.innerHTML = '';
const NS = 'http://www.w3.org/2000/svg';
// 기존 연결선 (화살표 없이 단순 곡선)
const self = this;
this.connections.forEach(conn => {
const d = this.getConnectionPath(conn);
if (!d) return;
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', d);
const isSel = this.selectedConnection?.id === conn.id;
path.setAttribute('stroke', isSel ? '#6366f1' : '#94a3b8');
path.setAttribute('stroke-width', isSel ? '3' : '2');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.style.pointerEvents = 'stroke';
path.style.cursor = 'pointer';
path.addEventListener('click', () => { self.selectConnection(conn); });
svg.appendChild(path);
});
// 드래그 중인 임시 연결선
if (this.drawingConnection && this.tempConnectionPath) {
const temp = document.createElementNS(NS, 'path');
temp.setAttribute('d', this.tempConnectionPath);
temp.setAttribute('stroke', '#6366f1');
temp.setAttribute('stroke-width', '2');
temp.setAttribute('stroke-dasharray', '6 3');
temp.setAttribute('fill', 'none');
temp.setAttribute('stroke-linecap', 'round');
temp.style.pointerEvents = 'none';
svg.appendChild(temp);
}
},
// ===== Connection Operations =====
startConnection(e, node, port) {
if (this.tool !== 'connect' && this.tool !== 'select') return;
this.drawingConnection = true;
this.connSource = node;
this.connSourcePort = port;
e.preventDefault();
},
getPortPos(node, port) {
const el = document.getElementById('node-' + node.id);
if (!el) return { x: node.x, y: node.y };
const w = el.offsetWidth || 160;
const h = el.offsetHeight || 80;
switch (port) {
case 'top': return { x: node.x + w / 2, y: node.y };
case 'bottom': return { x: node.x + w / 2, y: node.y + h };
case 'left': return { x: node.x, y: node.y + h / 2 };
case 'right': return { x: node.x + w, y: node.y + h / 2 };
default: return { x: node.x + w / 2, y: node.y + h / 2 };
}
},
getConnectionPath(conn) {
const fromNode = this.nodes.find(n => n.id === conn.from);
const toNode = this.nodes.find(n => n.id === conn.to);
if (!fromNode || !toNode) return '';
const from = this.getPortPos(fromNode, conn.fromPort || 'right');
const to = this.getPortPos(toNode, conn.toPort || 'left');
const dx = Math.abs(to.x - from.x) * 0.5;
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`;
},
selectConnection(conn) {
this.selectedConnection = conn;
this.selectedNode = null;
},
// ===== Canvas Events =====
onCanvasMouseDown(e) {
if (e.target.closest('.pc-node') || e.target.closest('.pc-port')) {
// 스페이스바 누른 채 노드 위에서도 패닝 가능
if (this.spaceHeld) {
this.panning = true;
this.panStartX = e.clientX - this.panX * this.zoom;
this.panStartY = e.clientY - this.panY * this.zoom;
document.getElementById('canvasWrap')?.style.setProperty('cursor', 'grabbing');
e.preventDefault();
}
return;
}
if (this.tool === 'pan' || this.spaceHeld || e.button === 1) {
this.panning = true;
this.panStartX = e.clientX - this.panX * this.zoom;
this.panStartY = e.clientY - this.panY * this.zoom;
document.getElementById('canvasWrap')?.style.setProperty('cursor', 'grabbing');
e.preventDefault();
} else {
this.selectedNode = null;
this.selectedConnection = null;
}
},
onCanvasMouseMove(e) {
if (this.panning) {
this.panX = (e.clientX - this.panStartX) / this.zoom;
this.panY = (e.clientY - this.panStartY) / this.zoom;
}
if (this.dragging && this.dragNode) {
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
this.dragNode.x = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom - this.dragOffsetX;
this.dragNode.y = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom - this.dragOffsetY;
this._connTick++;
}
if (this.drawingConnection && this.connSource) {
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
const mx = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom;
const my = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom;
const from = this.getPortPos(this.connSource, this.connSourcePort);
const dx = Math.abs(mx - from.x) * 0.5;
this.tempConnectionPath = `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${mx - dx} ${my}, ${mx} ${my}`;
}
},
onCanvasMouseUp(e) {
if (this.dragging) {
this.dragging = false;
this.dragNode = null;
this.pushHistory();
this.autoSave();
}
if (this.panning) {
this.panning = false;
const wrap = document.getElementById('canvasWrap');
if (this.spaceHeld) wrap?.style.setProperty('cursor', 'grab');
else wrap?.style.removeProperty('cursor');
}
if (this.drawingConnection) {
// Check if dropped on a node port
const target = e.target.closest('.pc-node');
if (target && this.connSource) {
const targetId = target.id.replace('node-', '');
if (targetId !== this.connSource.id) {
// Detect closest port
const port = e.target.classList.contains('pc-port-top') ? 'top'
: e.target.classList.contains('pc-port-bottom') ? 'bottom'
: e.target.classList.contains('pc-port-left') ? 'left'
: 'left';
const exists = this.connections.some(c => c.from === this.connSource.id && c.to === targetId);
if (!exists) {
this.connections.push({
id: 'c_' + Date.now(),
from: this.connSource.id,
to: targetId,
fromPort: this.connSourcePort,
toPort: port,
});
this.pushHistory();
this.autoSave();
}
}
}
this.drawingConnection = false;
this.connSource = null;
this.tempConnectionPath = '';
}
},
onNodeMouseDown(e, node) {
this.selectedNode = node;
this.selectedConnection = null;
this.sidebarTab = 'properties';
if (this.tool === 'select' || this.tool === 'pan') {
this.dragging = true;
this.dragNode = node;
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
this.dragOffsetX = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom - node.x;
this.dragOffsetY = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom - node.y;
}
},
onCanvasWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
this.zoom = Math.min(3, Math.max(0.2, this.zoom + delta));
},
// ===== Palette Drag & Drop =====
_dragPaletteItem: null,
onPaletteDragStart(e, item) {
this._dragPaletteItem = item;
e.dataTransfer.effectAllowed = 'copy';
},
onCanvasDrop(e) {
if (!this._dragPaletteItem) return;
const wrap = document.getElementById('canvasWrap');
const rect = wrap.getBoundingClientRect();
const x = (e.clientX - rect.left - this.panX * this.zoom) / this.zoom;
const y = (e.clientY - rect.top - this.panY * this.zoom) / this.zoom;
this.addNode(this._dragPaletteItem, x - 80, y - 30);
this._dragPaletteItem = null;
},
// ===== View Modes =====
switchView(mode) {
this.viewMode = mode;
if (mode === 'timeline') this.layoutTimeline();
else if (mode === 'flow') this.layoutFlow();
},
layoutTimeline() {
if (this.nodes.length === 0) return;
this.nodes.forEach((node, i) => {
const col = i % this.phases.length;
const row = Math.floor(i / this.phases.length);
node.x = col * 400 + 140;
node.y = row * 140 + 60;
});
this.pushHistory();
},
layoutFlow() {
if (this.nodes.length === 0) return;
const cols = Math.ceil(Math.sqrt(this.nodes.length));
this.nodes.forEach((node, i) => {
node.x = (i % cols) * 240 + 100;
node.y = Math.floor(i / cols) * 160 + 60;
});
this.pushHistory();
},
// ===== Zoom =====
zoomIn() { this.zoom = Math.min(3, this.zoom + 0.1); },
zoomOut() { this.zoom = Math.max(0.2, this.zoom - 0.1); },
zoomFit() {
if (this.nodes.length === 0) { this.zoom = 1; this.panX = 0; this.panY = 0; return; }
const wrap = document.getElementById('canvasWrap');
const minX = Math.min(...this.nodes.map(n => n.x)) - 50;
const minY = Math.min(...this.nodes.map(n => n.y)) - 50;
const maxX = Math.max(...this.nodes.map(n => n.x + 200)) + 50;
const maxY = Math.max(...this.nodes.map(n => n.y + 120)) + 50;
const w = maxX - minX;
const h = maxY - minY;
this.zoom = Math.min(1.5, Math.min(wrap.clientWidth / w, wrap.clientHeight / h));
this.panX = -minX + 20 / this.zoom;
this.panY = -minY + 20 / this.zoom;
},
// ===== Undo/Redo =====
pushHistory() {
const state = JSON.stringify({ nodes: this.nodes, connections: this.connections });
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(state);
this.historyIndex = this.history.length - 1;
if (this.history.length > 50) { this.history.shift(); this.historyIndex--; }
},
undoAction() {
if (this.historyIndex <= 0) return;
this.historyIndex--;
this._restoreState();
},
redoAction() {
if (this.historyIndex >= this.history.length - 1) return;
this.historyIndex++;
this._restoreState();
},
_restoreState() {
const state = JSON.parse(this.history[this.historyIndex]);
this.nodes = state.nodes;
this.connections = state.connections;
this.selectedNode = null;
this.selectedConnection = null;
},
// ===== Keyboard Shortcuts =====
handleKeyDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
// 스페이스바: 패닝 모드 (누르고 있는 동안)
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
if (!this.spaceHeld) {
this.spaceHeld = true;
this._toolBeforeSpace = this.tool;
this.tool = 'pan';
document.getElementById('canvasWrap')?.style.setProperty('cursor', 'grab');
}
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedConnection) {
this.deleteSelectedConnection();
} else {
this.deleteSelectedNode();
}
}
if (e.key === 'v' || e.key === 'V') this.tool = 'select';
if (e.key === 'h' || e.key === 'H') this.tool = 'pan';
if (e.key === 'c' || e.key === 'C') this.tool = 'connect';
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.undoAction(); }
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();
});
}
}
},
handleKeyUp(e) {
if (e.key === ' ' || e.code === 'Space') {
if (this.spaceHeld) {
this.spaceHeld = false;
this.panning = false;
this.tool = this._toolBeforeSpace || 'select';
document.getElementById('canvasWrap')?.style.removeProperty('cursor');
}
}
},
// ===== Connection Delete =====
deleteSelectedConnection() {
if (!this.selectedConnection) return;
this.connections = this.connections.filter(c => c.id !== this.selectedConnection.id);
this.selectedConnection = null;
this.pushHistory();
this.autoSave();
},
// ===== Storyboard =====
sbInitPages() {
if (!this.sb.pages || this.sb.pages.length === 0) {
this.sb.pages = [this.sbNewPageData()];
this.sb.currentPageIndex = 0;
}
},
sbNewPageData() {
return {
id: 'sp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
path: '',
screenName: '',
screenId: '',
wireframeContent: '',
wireframeImage: '',
descriptions: [],
};
},
sbAddPage() {
const newPage = this.sbNewPageData();
this.sb.pages.push(newPage);
this.sb.currentPageIndex = this.sb.pages.length - 1;
this.autoSave();
},
sbDeletePage() {
if (this.sb.pages.length <= 1) return;
if (!confirm('이 페이지를 삭제하시겠습니까?')) return;
this.sb.pages.splice(this.sb.currentPageIndex, 1);
if (this.sb.currentPageIndex >= this.sb.pages.length) {
this.sb.currentPageIndex = this.sb.pages.length - 1;
}
this.autoSave();
},
sbPrevPage() {
if (this.sb.currentPageIndex > 0) this.sb.currentPageIndex--;
},
sbNextPage() {
if (this.sb.currentPageIndex < this.sb.pages.length - 1) this.sb.currentPageIndex++;
},
sbAddDescription() {
const page = this.sbCurrentPage;
if (!page) return;
if (!page.descriptions) page.descriptions = [];
page.descriptions.push({ text: '' });
this.autoSave();
},
sbUploadImage(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const page = this.sbCurrentPage;
if (page) {
page.wireframeImage = reader.result;
page.wireframeContent = '';
this.autoSave();
}
};
reader.readAsDataURL(file);
e.target.value = '';
},
sbEditMenu() {
// deep copy menuTree → draft (+ _open 플래그 추가)
this.sbMenuDraft = JSON.parse(JSON.stringify(this.sb.menuTree)).map(m => {
m.children = m.children || [];
m._open = true;
return m;
});
this.sbMenuEditorOpen = true;
},
sbMenuApply() {
// _open 플래그 제거 후 적용
this.sb.menuTree = this.sbMenuDraft.map(m => {
const { _open, ...rest } = m;
rest.children = (rest.children || []).map(c => ({ name: c.name }));
return rest;
}).filter(m => m.name.trim() !== '');
this.sbMenuEditorOpen = false;
this.autoSave();
},
sbMenuToggle(mi) {
this.sbMenuDraft[mi]._open = this.sbMenuDraft[mi]._open === false ? true : false;
},
sbMenuAddChild(mi) {
if (!this.sbMenuDraft[mi].children) this.sbMenuDraft[mi].children = [];
this.sbMenuDraft[mi].children.push({ name: '' });
this.sbMenuDraft[mi]._open = true;
},
sbMenuRemove(mi) {
if (this.sbMenuDraft[mi].children?.length > 0 && !confirm('하위 메뉴도 함께 삭제됩니다. 계속하시겠습니까?')) return;
this.sbMenuDraft.splice(mi, 1);
},
sbMenuDragStart(level, key, e) {
this._sbMenuDrag = { level, key };
e.dataTransfer.effectAllowed = 'move';
},
sbMenuDragOver(level, key, e) {
if (!this._sbMenuDrag) return;
e.dataTransfer.dropEffect = 'move';
},
sbMenuDrop(level, key, e) {
if (!this._sbMenuDrag) return;
const src = this._sbMenuDrag;
this._sbMenuDrag = null;
if (src.level !== level) return;
if (level === 'root') {
const fromIdx = parseInt(src.key);
const toIdx = parseInt(key);
if (fromIdx === toIdx) return;
const item = this.sbMenuDraft.splice(fromIdx, 1)[0];
this.sbMenuDraft.splice(toIdx, 0, item);
} else {
// child: key format = "parentIdx-childIdx"
const [spi, sci] = src.key.split('-').map(Number);
const [dpi, dci] = key.split('-').map(Number);
if (spi === dpi && sci === dci) return;
if (spi === dpi) {
// 같은 부모 내 이동
const children = this.sbMenuDraft[spi].children;
const item = children.splice(sci, 1)[0];
children.splice(dci, 0, item);
} else {
// 다른 부모로 이동
const item = this.sbMenuDraft[spi].children.splice(sci, 1)[0];
this.sbMenuDraft[dpi].children.splice(dci, 0, item);
}
}
},
sbMenuDragEnd() {
this._sbMenuDrag = null;
},
sbExportHtml() {
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' +
(this.sb.docInfo.projectName || 'Storyboard') + '</title>' +
'<style>body{font-family:Pretendard,-apple-system,sans-serif;margin:0;padding:20px;background:#e5e7eb;}' +
'.page{width:1100px;margin:0 auto 24px;background:#fff;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.1);overflow:hidden;page-break-after:always;}' +
'.hdr{display:grid;grid-template-columns:1fr auto auto auto auto auto;border-bottom:2px solid #1e293b;font-size:10px;}' +
'.hdr>div{padding:6px 10px;border-right:1px solid #cbd5e1;}.hdr>div:last-child{border-right:none;}' +
'.lbl{font-size:8px;color:#94a3b8;font-weight:600;}.val{font-size:11px;font-weight:700;color:#1e293b;}' +
'.body{display:flex;min-height:500px;}.menu{width:160px;border-right:1px solid #e2e8f0;padding:12px 0;background:#f8fafc;font-size:11px;}' +
'.menu-logo{padding:8px 12px;font-size:13px;font-weight:800;}.menu-sec{font-size:8px;font-weight:700;color:#94a3b8;padding:4px 12px;}' +
'.menu-item{padding:5px 12px 5px 16px;color:#64748b;}.menu-item.active{color:#4338ca;font-weight:700;background:#eef2ff;border-right:3px solid #4338ca;}' +
'.menu-child{padding-left:28px;font-size:10px;}.menu-child.active{color:#4338ca;font-weight:700;}' +
'.content{flex:1;display:flex;flex-direction:column;}.wf{flex:1;padding:16px;}' +
'.wf img{max-width:100%;border-radius:4px;}.wf-text{border:1px solid #e2e8f0;border-radius:4px;padding:12px;min-height:200px;font-size:13px;line-height:1.7;}' +
'.desc{border-top:2px solid #1e293b;padding:12px 16px;background:#fafbfc;}' +
'.desc-title{font-size:10px;font-weight:700;margin-bottom:8px;}.desc-item{display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #f1f5f9;font-size:12px;line-height:1.6;}' +
'.desc-num{width:24px;height:24px;border-radius:50%;background:#1e293b;color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;}' +
'@media print{body{background:#fff;padding:0;}.page{box-shadow:none;margin:0;border-radius:0;}}</style></head><body>';
this.sb.pages.forEach((pg, idx) => {
html += '<div class="page"><div class="hdr">';
html += '<div><span class="lbl">단위업무명</span><br><span class="val">' + (this.sb.docInfo.unitTask || '-') + '</span></div>';
html += '<div><span class="lbl">버전</span><br><span class="val">' + (this.sb.docInfo.version || '-') + '</span></div>';
html += '<div><span class="lbl">Page</span><br><span class="val">' + (idx + 1) + '</span></div>';
html += '<div><span class="lbl">경로</span><br><span class="val">' + (pg.path || '-') + '</span></div>';
html += '<div><span class="lbl">화면명</span><br><span class="val">' + (pg.screenName || '-') + '</span></div>';
html += '<div><span class="lbl">화면 ID</span><br><span class="val">' + (pg.screenId || '-') + '</span></div>';
html += '</div><div class="body"><div class="menu">';
html += '<div class="menu-logo">' + (this.sb.docInfo.projectName || 'LOGO') + '</div>';
html += '<div class="menu-sec">ERP 메뉴</div>';
this.sb.menuTree.forEach(m => {
const isActive = pg.path && pg.path.startsWith(m.name);
html += '<div class="menu-item' + (isActive ? ' active' : '') + '">' + m.name + '</div>';
(m.children || []).forEach(c => {
const cActive = pg.path && pg.path.includes(c.name);
html += '<div class="menu-item menu-child' + (cActive ? ' active' : '') + '">- ' + c.name + '</div>';
});
});
html += '</div><div class="content"><div class="wf">';
if (pg.wireframeImage) html += '<img src="' + pg.wireframeImage + '">';
else if (pg.wireframeContent) html += '<div class="wf-text">' + pg.wireframeContent + '</div>';
else html += '<div class="wf-text" style="color:#94a3b8;">와이어프레임 영역</div>';
html += '</div>';
if (pg.descriptions && pg.descriptions.length > 0) {
html += '<div class="desc"><div class="desc-title">Description</div>';
pg.descriptions.forEach((d, di) => {
html += '<div class="desc-item"><div class="desc-num">' + String(di + 1).padStart(2, '0') + '</div>';
html += '<div>' + (d.text || '').replace(/\n/g, '<br>') + '</div></div>';
});
html += '</div>';
}
html += '</div></div></div>';
});
html += '</body></html>';
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (this.sb.docInfo.projectName || 'storyboard') + '.html';
a.click();
URL.revokeObjectURL(url);
},
// ===== Context Menu =====
showContextMenu(e) {
this.contextMenuPos = { x: e.clientX, y: e.clientY };
const menu = document.getElementById('contextMenu');
const wrapRect = document.getElementById('canvasWrap').getBoundingClientRect();
this.contextMenuPos.x = (e.clientX - wrapRect.left);
this.contextMenuPos.y = (e.clientY - wrapRect.top);
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.classList.add('show');
},
hideContextMenu() {
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; },
onPropChange() { this.autoSave(); },
statusColor(status) {
const map = { todo: '#94a3b8', progress: '#3b82f6', review: '#f59e0b', done: '#10b981' };
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] || '';
},
formatDate(iso) {
if (!iso) return '';
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>
@endpush