Files
sam-manage/resources/views/rd/planning-design/index.blade.php

2533 lines
122 KiB
PHP
Raw Normal View History

@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