10675 lines
374 KiB
HTML
10675 lines
374 KiB
HTML
<!doctype html>
|
||
<html lang="fa" dir="rtl">
|
||
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>دستیار انتشار</title>
|
||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||
<style>
|
||
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap");
|
||
|
||
:root {
|
||
--primary: #1a365d;
|
||
--primary-light: #2c5282;
|
||
--primary-dark: #0f2341;
|
||
--accent: #c9a961;
|
||
--accent-dark: #a8893f;
|
||
--accent-light: #e0c789;
|
||
--bg: #f5f7fa;
|
||
--board-bg: #e2e8f0;
|
||
--card-bg: #fff;
|
||
--panel-bg: #fff;
|
||
--text: #1a202c;
|
||
--text-muted: #4a5568;
|
||
--text-light: #718096;
|
||
--border: #cbd5e0;
|
||
--border-light: #e2e8f0;
|
||
--success: #38a169;
|
||
--danger: #e53e3e;
|
||
--warning: #dd6b20;
|
||
--info: #3182ce;
|
||
--column-bg: #edf2f7;
|
||
--shadow:
|
||
0 4px 6px rgba(0, 0, 0, 0.08), 0 10px 20px rgba(26, 54, 93, 0.1);
|
||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06);
|
||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||
--radius: 10px;
|
||
--radius-sm: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
--primary: #2c5282;
|
||
--primary-light: #3b6fb5;
|
||
--primary-dark: #1a365d;
|
||
--accent: #e0c789;
|
||
--accent-dark: #c9a961;
|
||
--accent-light: #f0d9a0;
|
||
--bg: #1a202c;
|
||
--board-bg: #2d3748;
|
||
--card-bg: #2d3748;
|
||
--panel-bg: #1f2937;
|
||
--text: #e2e8f0;
|
||
--text-muted: #cbd5e0;
|
||
--text-light: #a0aec0;
|
||
--column-bg: #374151;
|
||
--border: #4a5568;
|
||
--border-light: #374151;
|
||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3), 0 10px 20px rgba(0, 0, 0, 0.4);
|
||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: "Vazirmatn", Tahoma, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
font-size: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition:
|
||
background 0.3s,
|
||
color 0.3s;
|
||
}
|
||
|
||
.app-header {
|
||
background: linear-gradient(135deg,
|
||
var(--primary) 0%,
|
||
var(--primary-light) 100%);
|
||
color: #fff;
|
||
padding: 0 20px;
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 3px solid var(--accent);
|
||
box-shadow: var(--shadow);
|
||
z-index: 100;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.app-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
.app-logo {
|
||
width: 44px;
|
||
height: 44px;
|
||
background: var(--accent);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.app-brand-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.app-title {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
}
|
||
|
||
.app-subtitle {
|
||
font-size: 11px;
|
||
opacity: 0.85;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.btn {
|
||
background: linear-gradient(135deg,
|
||
var(--primary-light),
|
||
var(--primary));
|
||
color: #fff;
|
||
border: none;
|
||
padding: 8px 14px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
transition: all 0.2s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-1px);
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.btn-accent {
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||
color: var(--primary-dark);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: rgba(26, 54, 93, 0.08);
|
||
border: 1px solid var(--border);
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .btn-ghost {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, var(--danger), #c53030);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.btn-xs {
|
||
padding: 2px 6px;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.app-main {
|
||
flex: 1;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 280px;
|
||
background: var(--panel-bg);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: width 0.3s;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
z-index: 50;
|
||
}
|
||
|
||
.sidebar.collapsed {
|
||
width: 0;
|
||
}
|
||
|
||
.sidebar-header {
|
||
background: linear-gradient(135deg,
|
||
var(--primary),
|
||
var(--primary-light));
|
||
color: #fff;
|
||
padding: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 2px solid var(--accent);
|
||
min-height: 48px;
|
||
}
|
||
|
||
.sidebar-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.sidebar-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.sidebar-group {
|
||
margin-bottom: 6px;
|
||
border: 1px solid var(--border-light);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
background: var(--card-bg);
|
||
}
|
||
|
||
.sidebar-group-header {
|
||
background: linear-gradient(135deg,
|
||
var(--primary),
|
||
var(--primary-light));
|
||
color: #fff;
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
user-select: none;
|
||
}
|
||
|
||
.sidebar-group-header .group-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.sidebar-group-header .group-arrow {
|
||
transition: transform 0.3s;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.sidebar-group.collapsed .group-arrow {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.sidebar-group.collapsed .sidebar-group-body {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-group-body {
|
||
padding: 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.sidebar-btn {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-light);
|
||
color: var(--text);
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
text-align: right;
|
||
width: 100%;
|
||
}
|
||
|
||
.sidebar-btn:hover {
|
||
background: linear-gradient(135deg,
|
||
var(--primary),
|
||
var(--primary-light));
|
||
color: #fff;
|
||
}
|
||
|
||
.tabs-bar {
|
||
background: linear-gradient(135deg, #0f1724 0%, #1a2a44 100%);
|
||
border-bottom: 2px solid var(--accent);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
min-height: 48px;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-controls {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.toolbar-icon-btn {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
color: #fff;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.toolbar-icon-btn:hover:not(:disabled) {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.toolbar-search {
|
||
flex: 1;
|
||
min-width: 180px;
|
||
max-width: 320px;
|
||
position: relative;
|
||
}
|
||
|
||
.search-input {
|
||
width: 100%;
|
||
padding: 7px 32px 7px 12px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.search-input::placeholder {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.toolbar-filters {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.filter-dropdown {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.filter-select {
|
||
background: transparent;
|
||
border: none;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 7px 4px;
|
||
cursor: pointer;
|
||
outline: none;
|
||
min-width: 110px;
|
||
}
|
||
|
||
.filter-select option {
|
||
background: var(--primary-dark);
|
||
color: #fff;
|
||
}
|
||
|
||
.toolbar-stats {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-right: auto;
|
||
padding-right: 8px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
}
|
||
|
||
.stat-value {
|
||
font-weight: 800;
|
||
color: var(--accent);
|
||
background: rgba(201, 169, 97, 0.15);
|
||
padding: 2px 7px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.board-area {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 24px;
|
||
background: var(--board-bg);
|
||
}
|
||
|
||
.board-section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.board-section.collapsed .section-content {
|
||
display: none;
|
||
}
|
||
|
||
.board-section.collapsed .collapse-icon {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.collapse-icon {
|
||
transition: transform 0.3s;
|
||
font-size: 14px;
|
||
display: inline-block;
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.board-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 2px solid var(--border);
|
||
}
|
||
|
||
.board-header-title {
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
color: var(--primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
[data-theme="dark"] .board-header-title {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.board-header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.projects-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.project-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: all 0.25s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.project-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: var(--shadow-lg);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.project-card-header {
|
||
background: linear-gradient(135deg,
|
||
var(--primary) 0%,
|
||
var(--primary-light) 100%);
|
||
color: #fff;
|
||
padding: 16px;
|
||
}
|
||
|
||
.project-card-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.project-card-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
flex: 1;
|
||
}
|
||
|
||
.project-card-desc {
|
||
font-size: 12px;
|
||
opacity: 0.9;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.project-card-body {
|
||
padding: 14px 16px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.project-stat-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.project-stat-value {
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .project-stat-value {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.project-type-badges {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.type-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.type-backend {
|
||
background: #ebf4ff;
|
||
color: #2b6cb0;
|
||
}
|
||
|
||
.type-frontend {
|
||
background: #f0fff4;
|
||
color: #276749;
|
||
}
|
||
|
||
.type-service {
|
||
background: #fefcbf;
|
||
color: #975a16;
|
||
}
|
||
|
||
.type-database {
|
||
background: #fed7e2;
|
||
color: #97266d;
|
||
}
|
||
|
||
.type-mobile {
|
||
background: #e9d8fd;
|
||
color: #553c9a;
|
||
}
|
||
|
||
.type-infrastructure {
|
||
background: #feebc8;
|
||
color: #9c4221;
|
||
}
|
||
|
||
.project-card-footer {
|
||
padding: 12px 16px;
|
||
background: var(--border-light);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.env-card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.env-card-item {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.env-card-preview {
|
||
height: 60px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
|
||
.env-card-body {
|
||
padding: 14px 16px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.env-card-name {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.env-card-desc {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.env-card-footer {
|
||
padding: 10px 16px;
|
||
background: var(--border-light);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.board-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
padding: 40px;
|
||
}
|
||
|
||
.board-empty-icon {
|
||
font-size: 80px;
|
||
opacity: 0.3;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.board-empty h2 {
|
||
font-size: 20px;
|
||
color: var(--primary);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
[data-theme="dark"] .board-empty h2 {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.no-results {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
border-radius: var(--radius);
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(26, 54, 93, 0.6);
|
||
backdrop-filter: blur(3px);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
padding: 20px;
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--panel-bg);
|
||
color: var(--text);
|
||
border-radius: 12px;
|
||
width: 100%;
|
||
max-width: 600px;
|
||
max-height: 90vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.modal-lg {
|
||
max-width: 800px;
|
||
}
|
||
|
||
.modal-xl {
|
||
max-width: 1000px;
|
||
}
|
||
|
||
.modal-galaxy {
|
||
max-width: 1200px;
|
||
width: 100%;
|
||
height: 85vh;
|
||
max-height: 85vh;
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.galaxy-search {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
padding: 0 8px;
|
||
height: 32px;
|
||
min-width: 230px;
|
||
}
|
||
|
||
.galaxy-search-icon {
|
||
font-size: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.galaxy-search input {
|
||
flex: 1;
|
||
width: 140px;
|
||
background: transparent;
|
||
border: none;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
outline: none;
|
||
}
|
||
|
||
.galaxy-collapse-badge {
|
||
pointer-events: none;
|
||
}
|
||
|
||
.galaxy-search input::placeholder {
|
||
color: rgba(255, 255, 255, 0.55);
|
||
}
|
||
|
||
.galaxy-search-clear {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.galaxy-search-clear.visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.galaxy-node.galaxy-dim {
|
||
opacity: 0.12;
|
||
}
|
||
|
||
.galaxy-node.galaxy-match>circle:nth-of-type(2) {
|
||
stroke: #ffd700;
|
||
stroke-width: 3;
|
||
}
|
||
|
||
.galaxy-link.galaxy-dim {
|
||
stroke-opacity: 0.04 !important;
|
||
}
|
||
|
||
.modal-header {
|
||
background: linear-gradient(135deg,
|
||
var(--primary),
|
||
var(--primary-light));
|
||
color: #fff;
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 3px solid var(--accent);
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border: none;
|
||
color: #fff;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
gap: 8px;
|
||
background: var(--border-light);
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.detail-section {
|
||
margin-bottom: 20px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.detail-section-header {
|
||
background: linear-gradient(135deg,
|
||
var(--primary),
|
||
var(--primary-light));
|
||
color: #fff;
|
||
padding: 12px 16px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.detail-section-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.detail-section-body {
|
||
padding: 16px;
|
||
background: var(--card-bg);
|
||
}
|
||
|
||
.detail-info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-info-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.detail-info-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-info-value {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.subproject-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.subproject-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px;
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.subproject-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.subproject-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.subproject-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.subproject-meta {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.deployment-section {
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed var(--border);
|
||
}
|
||
|
||
.deployment-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.deployment-header-title {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.deployment-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.deployment-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.deployment-item-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.deployment-env-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.deployment-version {
|
||
font-family: "Vazirmatn", monospace;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
font-size: 11px;
|
||
}
|
||
|
||
[data-theme="dark"] .deployment-version {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.deployment-date {
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.deployment-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.deployment-notes {
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
padding-top: 4px;
|
||
border-top: 1px dashed var(--border-light);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.deployment-req {
|
||
font-size: 10px;
|
||
color: var(--info);
|
||
width: 100%;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.empty-deployments {
|
||
text-align: center;
|
||
padding: 12px;
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
background: var(--border-light);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.empty-subprojects {
|
||
text-align: center;
|
||
padding: 30px;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] .form-label {
|
||
color: var(--accent-light);
|
||
}
|
||
|
||
.form-label .required {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.form-input,
|
||
.form-textarea,
|
||
.form-select {
|
||
width: 100%;
|
||
padding: 9px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
.form-input:focus,
|
||
.form-textarea:focus,
|
||
.form-select:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(201, 169, 97, 0.15);
|
||
}
|
||
|
||
.form-textarea {
|
||
resize: vertical;
|
||
min-height: 70px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.form-error {
|
||
font-size: 11px;
|
||
color: var(--danger);
|
||
margin-top: 4px;
|
||
display: none;
|
||
}
|
||
|
||
.form-error.visible {
|
||
display: block;
|
||
}
|
||
|
||
.color-input-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.color-input-wrapper input[type="color"] {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
|
||
.color-preview-text {
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.webhook-section {
|
||
background: var(--border-light);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 14px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.webhook-section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.webhook-section-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.webhook-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 36px;
|
||
height: 20px;
|
||
}
|
||
|
||
.switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
inset: 0;
|
||
background: var(--border);
|
||
transition: 0.3s;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.slider::before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 14px;
|
||
width: 14px;
|
||
right: 3px;
|
||
bottom: 3px;
|
||
background: #fff;
|
||
transition: 0.3s;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
input:checked+.slider {
|
||
background: var(--success);
|
||
}
|
||
|
||
input:checked+.slider::before {
|
||
transform: translateX(-16px);
|
||
}
|
||
|
||
.webhook-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding: 8px 10px;
|
||
background: var(--card-bg);
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.webhook-status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--text-muted);
|
||
}
|
||
|
||
.webhook-status-dot.success {
|
||
background: var(--success);
|
||
}
|
||
|
||
.webhook-status-dot.error {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.toast-container {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
z-index: 2000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toast {
|
||
background: var(--primary);
|
||
color: #fff;
|
||
padding: 12px 18px;
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-lg);
|
||
font-size: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 250px;
|
||
}
|
||
|
||
.toast.success {
|
||
background: var(--success);
|
||
}
|
||
|
||
.toast.error {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.toast.warning {
|
||
background: var(--warning);
|
||
}
|
||
|
||
.app-footer {
|
||
background: linear-gradient(135deg,
|
||
var(--primary),
|
||
var(--primary-light));
|
||
color: #fff;
|
||
padding: 8px 20px;
|
||
font-size: 11px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-top: 2px solid var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.footer-brand {
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.form-input[readonly],
|
||
.form-textarea[readonly] {
|
||
background: var(--border-light);
|
||
color: var(--text-muted);
|
||
cursor: not-allowed;
|
||
opacity: 0.85;
|
||
border-style: dashed;
|
||
}
|
||
|
||
.readonly-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
border: 1px solid var(--border);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
margin-right: 6px;
|
||
}
|
||
|
||
.readonly-badge::before {
|
||
content: "🔒";
|
||
font-size: 10px;
|
||
}
|
||
|
||
.readonly-info {
|
||
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
|
||
border: 1px solid #90cdf4;
|
||
border-right: 4px solid var(--info);
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-bottom: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.dependency-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 20px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.dependency-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.dependency-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 1;
|
||
}
|
||
|
||
.dependency-project-name {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.dependency-search-box {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.dependency-search-input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.dependency-search-results {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.dependency-project-result {
|
||
margin-bottom: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dependency-project-add-btn {
|
||
padding: 10px 12px;
|
||
background: var(--card-bg);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
width: 100%;
|
||
border: none;
|
||
font-family: inherit;
|
||
text-align: right;
|
||
}
|
||
|
||
.dependency-project-add-btn:hover {
|
||
background: var(--accent-light);
|
||
}
|
||
|
||
.empty-dependencies {
|
||
text-align: center;
|
||
padding: 20px;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
background: var(--border-light);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.dependency-section-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] .dependency-section-title {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.env-color-preview {
|
||
padding: 10px;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
font-weight: 700;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.env-read-only-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.req-deployment-box {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px;
|
||
margin-bottom: 8px;
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.req-deployment-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
margin-bottom: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.req-deployment-select {
|
||
width: 100%;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
.galaxy-toolbar {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.galaxy-toolbar .btn {
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 5px 10px;
|
||
}
|
||
|
||
.galaxy-toolbar .btn:hover {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.galaxy-modal-body {
|
||
padding: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #0a0e27;
|
||
}
|
||
|
||
.galaxy-chart-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 400px;
|
||
position: relative;
|
||
}
|
||
|
||
.galaxy-chart-container svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: grab;
|
||
}
|
||
|
||
.galaxy-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
text-align: center;
|
||
font-size: 16px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.galaxy-legend {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
right: 16px;
|
||
background: rgba(10, 14, 39, 0.85);
|
||
border: 1px solid rgba(201, 169, 97, 0.3);
|
||
border-radius: 10px;
|
||
padding: 12px 16px;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
z-index: 10;
|
||
max-width: 220px;
|
||
}
|
||
|
||
.galaxy-legend-title {
|
||
font-weight: 700;
|
||
font-size: 12px;
|
||
margin-bottom: 10px;
|
||
color: var(--accent-light);
|
||
}
|
||
|
||
.galaxy-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.galaxy-legend-color {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 8px currentColor;
|
||
}
|
||
|
||
.galaxy-info-panel {
|
||
position: absolute;
|
||
top: 16px;
|
||
left: 16px;
|
||
background: rgba(10, 14, 39, 0.9);
|
||
border: 1px solid rgba(201, 169, 97, 0.3);
|
||
border-radius: 10px;
|
||
padding: 14px 18px;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
z-index: 10;
|
||
max-width: 340px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
display: none;
|
||
}
|
||
|
||
.galaxy-info-panel.visible {
|
||
display: block;
|
||
}
|
||
|
||
.galaxy-info-panel h4 {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--accent-light);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.galaxy-info-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 4px 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
gap: 12px;
|
||
}
|
||
|
||
.galaxy-info-label {
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.galaxy-info-value {
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
text-align: left;
|
||
}
|
||
|
||
.galaxy-zoom-indicator {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 16px;
|
||
background: rgba(10, 14, 39, 0.85);
|
||
border: 1px solid rgba(201, 169, 97, 0.3);
|
||
border-radius: 8px;
|
||
padding: 6px 12px;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
z-index: 10;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.galaxy-stats {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.galaxy-stat-value {
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
background: rgba(26, 54, 93, 0.1);
|
||
padding: 2px 8px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
[data-theme="dark"] .galaxy-stat-value {
|
||
color: var(--accent);
|
||
background: rgba(201, 169, 97, 0.15);
|
||
}
|
||
|
||
.galaxy-search-results {
|
||
position: absolute;
|
||
top: 60px;
|
||
right: 16px;
|
||
width: 320px;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
background: rgba(10, 14, 39, 0.95);
|
||
border: 1px solid rgba(201, 169, 97, 0.35);
|
||
border-radius: 10px;
|
||
color: #fff;
|
||
z-index: 20;
|
||
display: none;
|
||
padding: 6px;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.galaxy-search-results.visible {
|
||
display: block;
|
||
}
|
||
|
||
.galaxy-search-result-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.galaxy-search-result-item:hover {
|
||
background: rgba(201, 169, 97, 0.25);
|
||
}
|
||
|
||
.galaxy-search-result-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.galaxy-search-result-text {
|
||
min-width: 0;
|
||
}
|
||
|
||
.galaxy-search-result-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.galaxy-search-result-sub {
|
||
font-size: 10px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.galaxy-search-empty {
|
||
padding: 14px;
|
||
text-align: center;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.galaxy-node.galaxy-focus>circle:nth-of-type(2) {
|
||
stroke: #ffd700;
|
||
stroke-width: 4;
|
||
animation: galaxyPulse 0.9s ease-in-out 2;
|
||
}
|
||
|
||
.galaxy-node circle {
|
||
transform-box: fill-box;
|
||
transform-origin: center;
|
||
}
|
||
|
||
@keyframes galaxyPulse {
|
||
|
||
0%,
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
|
||
50% {
|
||
transform: scale(1.15);
|
||
}
|
||
}
|
||
|
||
.dashboard-modal-body {
|
||
padding: 20px;
|
||
background: var(--bg);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.dashboard-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.dashboard-kpi-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.dashboard-kpi-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 16px;
|
||
text-align: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dashboard-kpi-card::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
left: 0;
|
||
height: 3px;
|
||
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||
}
|
||
|
||
.dashboard-kpi-icon {
|
||
font-size: 28px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.dashboard-kpi-value {
|
||
font-size: 28px;
|
||
font-weight: 800;
|
||
color: var(--primary);
|
||
line-height: 1;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
[data-theme="dark"] .dashboard-kpi-value {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.dashboard-kpi-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.dashboard-charts-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.dashboard-chart-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dashboard-chart-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .dashboard-chart-header {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.dashboard-chart-body {
|
||
padding: 16px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 320px;
|
||
min-height: 320px;
|
||
max-height: 320px;
|
||
overflow: auto;
|
||
position: relative;
|
||
}
|
||
|
||
.dashboard-chart-body svg {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
.dashboard-empty-chart {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
gap: 8px;
|
||
text-align: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.chart-zoom-container {
|
||
transform-origin: center center;
|
||
transition: transform 0.15s ease;
|
||
cursor: grab;
|
||
}
|
||
|
||
.chart-zoom-container:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.chart-zoom-indicator {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 8px;
|
||
background: rgba(26, 54, 93, 0.85);
|
||
color: #fff;
|
||
padding: 3px 8px;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
z-index: 5;
|
||
}
|
||
|
||
.chart-zoom-indicator.visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
.chart-zoom-reset {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
background: rgba(26, 54, 93, 0.85);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
z-index: 5;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.chart-zoom-reset.visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
.chart-zoom-reset:hover {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.dashboard-empty-chart span {
|
||
font-size: 32px;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.dashboard-footer-stats {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.dashboard-footer-stats .stat-highlight {
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .dashboard-footer-stats .stat-highlight {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.d3-tooltip {
|
||
position: fixed;
|
||
background: rgba(15, 35, 65, 0.95);
|
||
color: #fff;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
pointer-events: none;
|
||
z-index: 10000;
|
||
display: none;
|
||
font-family: "Vazirmatn", sans-serif;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
border: 1px solid rgba(201, 169, 97, 0.4);
|
||
max-width: 280px;
|
||
word-wrap: break-word;
|
||
direction: rtl;
|
||
backdrop-filter: none;
|
||
}
|
||
|
||
.d3-tooltip.visible {
|
||
display: block;
|
||
animation: tooltipFadeIn 0.15s ease;
|
||
}
|
||
|
||
@keyframes tooltipFadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(2px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.rb-toolbar {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
padding: 8px 12px;
|
||
background: linear-gradient(135deg,
|
||
var(--primary-dark),
|
||
var(--primary));
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.rb-toolbar .btn {
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 5px 10px;
|
||
}
|
||
|
||
.rb-toolbar .btn:hover {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.rb-toolbar .btn.btn-accent {
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||
color: var(--primary-dark);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.rb-toolbar .btn.btn-danger {
|
||
background: linear-gradient(135deg, #f56565, #e53e3e);
|
||
color: #fff;
|
||
}
|
||
|
||
.rb-toolbar-separator {
|
||
width: 1px;
|
||
height: 24px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
margin: 0 4px;
|
||
}
|
||
|
||
.rb-search-box {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
padding: 0 10px;
|
||
height: 32px;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.rb-search-box input {
|
||
flex: 1;
|
||
width: 120px;
|
||
background: transparent;
|
||
border: none;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
outline: none;
|
||
}
|
||
|
||
.rb-search-box input::placeholder {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.rb-search-clear {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
padding: 2px 4px;
|
||
}
|
||
|
||
.rb-search-clear.visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.rb-search-clear:hover {
|
||
color: #fff;
|
||
}
|
||
|
||
.rb-filter-status {
|
||
font-size: 10px;
|
||
color: var(--accent);
|
||
background: rgba(201, 169, 97, 0.15);
|
||
padding: 3px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 700;
|
||
display: none;
|
||
}
|
||
|
||
.rb-filter-status.visible {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.rb-tag-filter-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
background: linear-gradient(135deg,
|
||
rgba(15, 23, 36, 0.95),
|
||
rgba(26, 42, 68, 0.95));
|
||
border-bottom: 1px solid var(--border);
|
||
flex-wrap: wrap;
|
||
min-height: 40px;
|
||
}
|
||
|
||
.rb-tag-filter-label {
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rb-filter-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 10px;
|
||
border-radius: 14px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: all 0.2s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rb-filter-chip:hover {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.rb-filter-chip.active {
|
||
background: var(--chip-color, #4299e1);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.rb-filter-chip .chip-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.rb-filter-chip.active .chip-dot {
|
||
background: #fff !important;
|
||
}
|
||
|
||
.rb-filter-clear-btn {
|
||
background: rgba(229, 62, 62, 0.2);
|
||
border: 1px solid rgba(229, 62, 62, 0.4);
|
||
color: #feb2b2;
|
||
font-size: 10px;
|
||
padding: 3px 8px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-weight: 600;
|
||
display: none;
|
||
}
|
||
|
||
.rb-filter-clear-btn.visible {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.rb-filter-clear-btn:hover {
|
||
background: rgba(229, 62, 62, 0.4);
|
||
}
|
||
|
||
.rb-filter-empty {
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.rb-tabs-bar {
|
||
background: linear-gradient(135deg,
|
||
var(--primary-dark) 0%,
|
||
var(--primary) 100%);
|
||
border-bottom: 2px solid var(--accent);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 10px;
|
||
min-height: 42px;
|
||
gap: 3px;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.rb-tabs-bar::-webkit-scrollbar {
|
||
height: 4px;
|
||
}
|
||
|
||
.rb-tabs-bar::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.rb-tab-item {
|
||
padding: 7px 14px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
border-radius: 6px 6px 0 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
transition: all 0.2s;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
|
||
.rb-tab-item:hover {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
color: #fff;
|
||
}
|
||
|
||
.rb-tab-item.active {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
font-weight: 700;
|
||
border-bottom-color: var(--primary-dark);
|
||
}
|
||
|
||
.rb-tab-close {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: inherit;
|
||
font-size: 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0.6;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.rb-tab-item:hover .rb-tab-close {
|
||
opacity: 1;
|
||
}
|
||
|
||
.rb-tab-close:hover {
|
||
background: var(--danger);
|
||
color: #fff;
|
||
}
|
||
|
||
.rb-tab-add {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px dashed rgba(255, 255, 255, 0.3);
|
||
color: rgba(255, 255, 255, 0.7);
|
||
padding: 7px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.rb-tab-add:hover {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.rb-empty-tabs {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
font-size: 12px;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.rb-board-area {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 20px;
|
||
background: var(--board-bg);
|
||
}
|
||
|
||
.rb-board-wrapper {
|
||
display: flex;
|
||
gap: 15px;
|
||
align-items: flex-start;
|
||
min-height: calc(100% - 40px);
|
||
padding-bottom: 20px;
|
||
}
|
||
|
||
.rb-empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
}
|
||
|
||
.rb-empty-state-icon {
|
||
font-size: 80px;
|
||
opacity: 0.3;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.rb-default-columns-switch {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px;
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.rb-switch-label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.rb-switch-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.rb-switch-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.rb-switch-desc {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.rb-default-columns-preview {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed var(--border);
|
||
flex-wrap: wrap;
|
||
animation: rbFadeIn 0.3s ease;
|
||
}
|
||
|
||
.rb-default-columns-preview.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.rb-preview-column {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||
box-shadow: var(--shadow-sm);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.rb-preview-column:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
@keyframes rbFadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-5px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.column {
|
||
border-radius: var(--radius);
|
||
min-width: 300px;
|
||
max-width: 300px;
|
||
max-height: calc(100% - 20px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: var(--shadow-sm);
|
||
border: 1px solid var(--border);
|
||
transition: all 0.2s;
|
||
background: var(--column-bg);
|
||
}
|
||
|
||
.column-header {
|
||
padding: 12px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid var(--border);
|
||
background: linear-gradient(to bottom,
|
||
rgba(255, 255, 255, 0.3),
|
||
transparent);
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
}
|
||
|
||
.column-title {
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.column-count {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 700;
|
||
background: rgba(0, 0, 0, 0.15);
|
||
color: inherit;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.column-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.column-action-btn {
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: inherit;
|
||
opacity: 0.7;
|
||
padding: 4px 6px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.column-action-btn:hover {
|
||
background: rgba(0, 0, 0, 0.1);
|
||
opacity: 1;
|
||
}
|
||
|
||
.column-body {
|
||
padding: 10px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.column-body::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.column-body::-webkit-scrollbar-thumb {
|
||
background: rgba(0, 0, 0, 0.15);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.column-footer {
|
||
padding: 8px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.add-card-btn {
|
||
width: 100%;
|
||
background: transparent;
|
||
border: 1px dashed var(--border);
|
||
color: var(--text-muted);
|
||
padding: 8px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.add-card-btn:hover {
|
||
background: var(--card-bg);
|
||
border-color: var(--accent);
|
||
color: var(--accent-dark);
|
||
}
|
||
|
||
.add-column-btn {
|
||
background: rgba(255, 255, 255, 0.5);
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius);
|
||
min-width: 280px;
|
||
min-height: 100px;
|
||
cursor: pointer;
|
||
color: var(--text-muted);
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
[data-theme="dark"] .add-column-btn {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.add-column-btn:hover {
|
||
background: var(--card-bg);
|
||
border-color: var(--accent);
|
||
color: var(--accent-dark);
|
||
}
|
||
|
||
.card {
|
||
background: var(--card-bg);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
transition: all 0.15s;
|
||
position: relative;
|
||
}
|
||
|
||
.card:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||
transform: translateY(-1px);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.card:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.card-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.card-tag {
|
||
font-size: 10px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
color: white;
|
||
display: inline-block;
|
||
}
|
||
|
||
.card-title {
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
margin-bottom: 6px;
|
||
line-height: 1.5;
|
||
color: var(--text);
|
||
}
|
||
|
||
.card-desc {
|
||
font-size: 12px;
|
||
margin-bottom: 8px;
|
||
line-height: 1.6;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
opacity: 0.85;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.card-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
align-items: center;
|
||
opacity: 0.9;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.card-meta-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
background: rgba(0, 0, 0, 0.08);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
[data-theme="dark"] .card-meta-item {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
color: var(--text);
|
||
}
|
||
|
||
.card-meta-item.overdue {
|
||
background: #fed7d7;
|
||
color: var(--danger);
|
||
font-weight: 600;
|
||
opacity: 1;
|
||
}
|
||
|
||
.card-meta-item.soon {
|
||
background: #fef5e7;
|
||
color: var(--warning);
|
||
font-weight: 600;
|
||
opacity: 1;
|
||
}
|
||
|
||
[data-theme="dark"] .card-meta-item.overdue {
|
||
background: rgba(229, 62, 62, 0.3);
|
||
color: #feb2b2;
|
||
}
|
||
|
||
[data-theme="dark"] .card-meta-item.soon {
|
||
background: rgba(221, 107, 32, 0.3);
|
||
color: #fbd38d;
|
||
}
|
||
|
||
.card-checklist-progress {
|
||
width: 100%;
|
||
height: 4px;
|
||
background: rgba(0, 0, 0, 0.1);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
[data-theme="dark"] .card-checklist-progress {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
}
|
||
|
||
.card-checklist-bar {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--success), #48bb78);
|
||
transition: width 0.3s;
|
||
}
|
||
|
||
.tag-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: white;
|
||
transition: all 0.15s;
|
||
border: 2px solid transparent;
|
||
user-select: none;
|
||
}
|
||
|
||
.tag-chip.selected {
|
||
border-color: var(--primary);
|
||
transform: scale(1.05);
|
||
box-shadow:
|
||
0 0 0 2px var(--card-bg),
|
||
0 0 0 4px var(--primary);
|
||
}
|
||
|
||
.tag-chip:hover {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.rb-tag-add-form {
|
||
background: var(--border-light);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.rb-tag-list-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.rb-tag-count {
|
||
background: var(--primary);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.rb-tag-list {
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.rb-tag-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.rb-tag-list::-webkit-scrollbar-thumb {
|
||
background: rgba(0, 0, 0, 0.15);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.rb-tag-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.rb-tag-item:hover {
|
||
border-color: var(--accent);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.rb-tag-color-dot {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.rb-tag-name {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
|
||
.rb-tag-usage {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.rb-tag-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.rb-tag-action-btn {
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 4px 6px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
opacity: 0.6;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.rb-tag-action-btn:hover {
|
||
opacity: 1;
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.rb-tag-action-btn.danger:hover {
|
||
background: #fed7d7;
|
||
}
|
||
|
||
.rb-tag-empty {
|
||
text-align: center;
|
||
padding: 30px 10px;
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.rb-tag-empty-icon {
|
||
font-size: 40px;
|
||
opacity: 0.3;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.rb-board-area .empty-state {
|
||
text-align: center;
|
||
padding: 20px 10px;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.card.hidden-by-filter {
|
||
display: none !important;
|
||
}
|
||
|
||
.card.dragging {
|
||
opacity: 0.4;
|
||
transform: rotate(3deg);
|
||
box-shadow: var(--shadow-lg);
|
||
border-style: dashed;
|
||
}
|
||
|
||
.column.drag-over {
|
||
outline: 3px dashed var(--accent);
|
||
outline-offset: -3px;
|
||
background: rgba(201, 169, 97, 0.08) !important;
|
||
}
|
||
|
||
.column.drag-over .column-body {
|
||
background: rgba(201, 169, 97, 0.05);
|
||
}
|
||
|
||
.column.dragging-column {
|
||
opacity: 0.5;
|
||
border-style: dashed;
|
||
}
|
||
|
||
.column.drag-over-column {
|
||
outline: 3px dashed var(--info);
|
||
outline-offset: -3px;
|
||
}
|
||
|
||
.column-header[draggable="true"] {
|
||
cursor: grab;
|
||
}
|
||
|
||
.column-header[draggable="true"]:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.drag-handle-hint {
|
||
opacity: 0.4;
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
cursor: grab;
|
||
}
|
||
|
||
.column-drop-indicator {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 4px;
|
||
background: var(--accent);
|
||
border-radius: 2px;
|
||
z-index: 10;
|
||
}
|
||
|
||
/* ── حالت خالی هنگام فیلتر ── */
|
||
.column-filter-empty {
|
||
text-align: center;
|
||
padding: 15px 10px;
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
background: rgba(0, 0, 0, 0.03);
|
||
border-radius: 6px;
|
||
margin-top: 4px;
|
||
display: none;
|
||
}
|
||
|
||
.column-filter-empty.visible {
|
||
display: block;
|
||
}
|
||
|
||
/* ── استایل آیتمهای وابستگی و مسئول ── */
|
||
.rb-item-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 10px;
|
||
border-radius: 14px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.rb-item-chip:hover {
|
||
border-color: var(--accent);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.rb-item-chip .chip-remove {
|
||
cursor: pointer;
|
||
opacity: 0.6;
|
||
font-size: 12px;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.rb-item-chip .chip-remove:hover {
|
||
opacity: 1;
|
||
color: var(--danger);
|
||
}
|
||
|
||
.rb-item-chip.assignee-chip {
|
||
background: #e6f4ff;
|
||
border-color: #91caff;
|
||
color: #0958d9;
|
||
}
|
||
|
||
.rb-item-chip.dependency-chip {
|
||
background: #fff7e6;
|
||
border-color: #ffd591;
|
||
color: #d46b08;
|
||
}
|
||
|
||
/* ── استایل تاریخچه ── */
|
||
.rb-history-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding: 6px 8px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.rb-history-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.rb-history-icon {
|
||
font-size: 13px;
|
||
flex-shrink: 0;
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.rb-history-content {
|
||
flex: 1;
|
||
color: var(--text);
|
||
}
|
||
|
||
.rb-history-date {
|
||
color: var(--text-muted);
|
||
font-size: 10px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── استایل نشانگر وابستگی در کارت ── */
|
||
.card-deps {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 3px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.card-dep-badge {
|
||
font-size: 9px;
|
||
padding: 1px 6px;
|
||
border-radius: 8px;
|
||
background: #fff7e6;
|
||
border: 1px solid #ffd591;
|
||
color: #d46b08;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.card-assignees {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 3px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.card-assignee-badge {
|
||
font-size: 9px;
|
||
padding: 1px 6px;
|
||
border-radius: 8px;
|
||
background: #e6f4ff;
|
||
border: 1px solid #91caff;
|
||
color: #0958d9;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ── اصلاح فیلتر: غیرفعال/فعال ── */
|
||
.rb-filter-clear-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 4px;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<header class="app-header">
|
||
<div class="app-brand">
|
||
<div class="app-logo">📊</div>
|
||
<div class="app-brand-text">
|
||
<div class="app-title" id="headerTitle">دستیار انتشار</div>
|
||
<div class="app-subtitle" id="headerSubtitle">
|
||
مدیریت نسخهها و نیازمندیهای محصولات
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div class="tabs-bar" id="advancedToolbar">
|
||
<div class="toolbar-controls">
|
||
<button class="toolbar-icon-btn" id="sidebarToggle" title="سایدبار">
|
||
<span id="sidebarIcon">✕</span>
|
||
</button>
|
||
<button class="toolbar-icon-btn" id="themeToggle" title="تم">
|
||
<span id="themeIcon">🌙</span>
|
||
</button>
|
||
<button class="toolbar-icon-btn" id="undoBtn" title="Ctrl+Z" disabled>
|
||
↩
|
||
</button>
|
||
<button class="toolbar-icon-btn" id="redoBtn" title="Ctrl+Y" disabled>
|
||
↪
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-search">
|
||
<span style="position: absolute; right: 10px; opacity: 0.7">🔍</span>
|
||
<input type="text" class="search-input" id="toolbarSearch" placeholder="جستجو..." />
|
||
</div>
|
||
<div class="toolbar-filters">
|
||
<div class="filter-dropdown">
|
||
<span>🏷️</span>
|
||
<select class="filter-select" id="filterType">
|
||
<option value="">همه انواع</option>
|
||
<option value="backend">بکاند</option>
|
||
<option value="frontend">فرانتاند</option>
|
||
<option value="service">میکروسرویس</option>
|
||
<option value="database">پایگاه داده</option>
|
||
<option value="mobile">موبایل</option>
|
||
<option value="infrastructure">زیرساخت</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-dropdown">
|
||
<span>🌐</span>
|
||
<select class="filter-select" id="filterEnvironment">
|
||
<option value="">همه محیطها</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-dropdown">
|
||
<span>👤</span>
|
||
<select class="filter-select" id="filterOwner">
|
||
<option value="">همه مالکان</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar-stats">
|
||
<div class="stat-item">
|
||
📋 <span class="stat-value" id="statTotal">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
📦 <span class="stat-value" id="statProjects">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
🌿 <span class="stat-value" id="statSubProjects">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
🔗 <span class="stat-value" id="statDependencies">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="app-main">
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="sidebar-title">🛠️ ابزارها</div>
|
||
</div>
|
||
<div class="sidebar-body" id="sidebarBody"></div>
|
||
</aside>
|
||
<main class="board-area" id="boardArea"></main>
|
||
</div>
|
||
<footer class="app-footer" id="appFooter">
|
||
<div>
|
||
<span class="footer-brand">طراح و توسعهدهنده:</span> هادی خزاعی اصل
|
||
<span style="margin: 0 10px">|</span><span class="footer-brand">شرکت:</span> رایان پرداز کاوش
|
||
</div>
|
||
<div><span class="footer-brand">نسخه:</span> 1.2.0</div>
|
||
</footer>
|
||
|
||
<!-- Settings Modal -->
|
||
<div class="modal-overlay" id="settingsModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">
|
||
<span>⚙️</span><span>تنظیمات محصول</span>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="readonly-info">
|
||
ℹ️ اطلاعات پایه محصول فقط قابل مشاهده هستند.
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان محصول
|
||
<span class="readonly-badge">فقط خواندنی</span></label><input type="text" class="form-input"
|
||
id="settingProjectTitle" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">نسخه <span class="readonly-badge">فقط خواندنی</span></label><input type="text"
|
||
class="form-input" id="settingVersion" readonly />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات <span class="readonly-badge">فقط خواندنی</span></label><textarea
|
||
class="form-textarea" id="settingDescription" readonly></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">توسعهدهنده
|
||
<span class="readonly-badge">فقط خواندنی</span></label><input type="text" class="form-input"
|
||
id="settingDeveloper" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">شرکت <span class="readonly-badge">فقط خواندنی</span></label><input type="text"
|
||
class="form-input" id="settingCompany" readonly />
|
||
</div>
|
||
</div>
|
||
<div class="webhook-section">
|
||
<div class="webhook-section-header">
|
||
<div class="webhook-section-title">🔗 تنظیمات وبهوک</div>
|
||
<div class="webhook-toggle">
|
||
<span>فعالسازی</span><label class="switch"><input type="checkbox" id="settingWebhookEnabled" /><span
|
||
class="slider"></span></label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">آدرس URL وبهوک <span class="required">*</span></label><input type="url"
|
||
class="form-input" id="settingWebhookUrl" dir="ltr" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">شناسه کلاینت <span class="required">*</span></label><input type="text"
|
||
class="form-input" id="settingWebhookClientId" dir="ltr" />
|
||
</div>
|
||
<div class="webhook-status">
|
||
<span class="webhook-status-dot" id="webhookStatusDot"></span><span id="webhookStatusText">وضعیت:
|
||
غیرفعال</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveSettingsBtn">💾 ذخیره</button>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Environment Modals -->
|
||
<div class="modal-overlay" id="envManagerModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🌍 افزودن محیط عملیاتی جدید</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">نام محیط <span class="required">*</span></label><input type="text"
|
||
class="form-input" id="newEnvName" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label><input type="text" class="form-input" id="newEnvDesc" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ پسزمینه</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="newEnvBgColor" value="#38a169" /><span class="color-preview-text"
|
||
id="newEnvBgColorText">#38a169</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ متن</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="newEnvTextColor" value="#ffffff" /><span class="color-preview-text"
|
||
id="newEnvTextColorText">#ffffff</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveNewEnvBtn">💾 ثبت</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="envEditModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش محیط عملیاتی</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">شناسه <span class="readonly-badge">فقط خواندنی</span></label><input type="text"
|
||
class="form-input" id="editEnvId" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">نام <span class="required">*</span></label><input type="text" class="form-input"
|
||
id="editEnvName" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label><input type="text" class="form-input" id="editEnvDesc" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ پسزمینه</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="editEnvBgColor" /><span class="color-preview-text" id="editEnvBgColorText"></span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ متن</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="editEnvTextColor" /><span class="color-preview-text"
|
||
id="editEnvTextColorText"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">پیشنمایش</label>
|
||
<div id="editEnvPreview" class="env-color-preview">نمونه</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveEnvEditBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Product Modals -->
|
||
<div class="modal-overlay" id="projectDetailModal">
|
||
<div class="modal modal-xl">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="projectDetailTitle">📦 جزئیات محصول</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body" id="projectDetailBody"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-danger" id="projectDetailDeleteBtn">
|
||
🗑️ حذف محصول</button><button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="productEditModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش محصول</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">شناسه <span class="readonly-badge">فقط خواندنی</span></label><input type="text"
|
||
class="form-input" id="editProductId" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان <span class="required">*</span></label><input type="text" class="form-input"
|
||
id="editProductTitle" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label><textarea class="form-textarea"
|
||
id="editProductDescription"></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">مالک</label><input type="text" class="form-input" id="editProductOwner" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">تاریخ ایجاد
|
||
<span class="readonly-badge">فقط خواندنی</span></label><input type="text" class="form-input"
|
||
id="editProductCreatedAt" readonly />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveProductEditBtn">
|
||
💾 ذخیره</button><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="addProductModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">➕ ایجاد محصول جدید</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان <span class="required">*</span></label><input type="text" class="form-input"
|
||
id="newProductTitle" />
|
||
<div class="form-error" id="newProductTitleError">
|
||
عنوان محصول الزامی است و باید حداقل ۳ کاراکتر باشد
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label><textarea class="form-textarea"
|
||
id="newProductDescription"></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">مالک</label><input type="text" class="form-input" id="newProductOwner" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">تاریخ ایجاد
|
||
<span class="readonly-badge">فقط خواندنی</span></label><input type="text" class="form-input"
|
||
id="newProductCreatedAt" readonly />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveNewProductBtn">💾 ثبت</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="addProjectModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">➕ افزودن پروژه جدید</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان <span class="required">*</span></label>
|
||
<input type="text" class="form-input" id="newProjectTitle" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">نوع <span class="required">*</span></label>
|
||
<select class="form-select" id="newProjectType">
|
||
<option value="">انتخاب کنید...</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">فریمورک</label>
|
||
<input type="text" class="form-input" id="newProjectFramework" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">مالک پروژه</label>
|
||
<input type="text" class="form-input" id="newProjectOwner" placeholder="نام مسئول پروژه..." />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">زبان</label>
|
||
<input type="text" class="form-input" id="newProjectLanguage" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">آدرس مخزن</label>
|
||
<input type="text" class="form-input" id="newProjectRepo" dir="ltr" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label>
|
||
<textarea class="form-textarea" id="newProjectDescription"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveNewProjectBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="editProjectModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش پروژه</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">شناسه <span class="readonly-badge">فقط خواندنی</span></label>
|
||
<input type="text" class="form-input" id="editProjId" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان <span class="required">*</span></label>
|
||
<input type="text" class="form-input" id="editProjTitle" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">نوع <span class="required">*</span></label>
|
||
<select class="form-select" id="editProjType">
|
||
<option value="">انتخاب کنید...</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">فریمورک</label>
|
||
<input type="text" class="form-input" id="editProjFramework" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">مالک پروژه</label>
|
||
<input type="text" class="form-input" id="editProjOwner" placeholder="نام مسئول پروژه..." />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">زبان</label>
|
||
<input type="text" class="form-input" id="editProjLanguage" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">آدرس مخزن</label>
|
||
<input type="text" class="form-input" id="editProjRepo" dir="ltr" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label>
|
||
<textarea class="form-textarea" id="editProjDescription"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveProjEditBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Deployment Modal -->
|
||
<div class="modal-overlay" id="deploymentModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="deploymentModalTitle">
|
||
🚀 افزودن نسخه استقرار
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group" id="deploymentEnvGroup"></div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">نسخه <span class="required">*</span></label><input type="text" class="form-input"
|
||
id="depVersion" dir="ltr" placeholder="مثلاً: 1.2.3" />
|
||
<div class="form-hint">از فرمت SemVer استفاده کنید</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">تاریخ و زمان استقرار <span class="required">*</span></label><input
|
||
type="datetime-local" class="form-input" id="depDate" dir="ltr" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">ریلیز نوت <span class="required">*</span></label><textarea class="form-textarea"
|
||
id="depReleaseNotes" rows="3" placeholder="توضیحات تغییرات این نسخه..."></textarea>
|
||
<div class="form-hint">
|
||
شرح تغییرات، باگفیکسها و ویژگیهای جدید این نسخه
|
||
</div>
|
||
</div>
|
||
<div id="requiredDeploymentsSection"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveDeploymentBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dependency Manager Modal -->
|
||
<div class="modal-overlay" id="dependencyManagerModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔗 مدیریت وابستگیها</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="readonly-info">
|
||
ℹ️ هر وابستگی فقط شامل یک پروژه است. انتخاب نسخه و محیط در زمان ثبت
|
||
استقرار انجام میشود.
|
||
</div>
|
||
<div class="dependency-section-title">📋 وابستگیهای فعلی</div>
|
||
<div id="currentDependenciesList" class="dependency-list"></div>
|
||
<div class="dependency-search-box">
|
||
<div class="dependency-section-title">🔍 افزودن وابستگی جدید</div>
|
||
<input type="text" class="dependency-search-input" id="dependencySearch" placeholder="جستجو در پروژهها..." />
|
||
<div id="dependencySearchResults" class="dependency-search-results"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Exploit Manager Modal -->
|
||
<div class="modal-overlay" id="exploitManagerModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔌 مدیریت سرویسهای مورد استفاده</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="readonly-info">
|
||
ℹ️ سرویسهایی که این پروژه از آنها استفاده میکند (ارتباط نرم). انتخاب نسخه و محیط در زمان ثبت استقرار انجام
|
||
میشود.
|
||
</div>
|
||
<div class="dependency-section-title">📋 سرویسهای مورد استفاده فعلی</div>
|
||
<div id="currentExploitsList" class="dependency-list"></div>
|
||
<div class="dependency-search-box">
|
||
<div class="dependency-section-title">🔍 افزودن سرویس جدید</div>
|
||
<input type="text" class="dependency-search-input" id="exploitSearch" placeholder="جستجو در پروژهها..." />
|
||
<div id="exploitSearchResults" class="dependency-search-results"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Galaxy Chart Modal -->
|
||
<div class="modal-overlay" id="galaxyChartModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🌌 نمودار کهکشانی محصولات</div>
|
||
<div class="galaxy-toolbar">
|
||
<div class="galaxy-search">
|
||
<span class="galaxy-search-icon">🔍</span>
|
||
<input type="text" id="galaxySearchInput" placeholder="جستجو در نمودار..." autocomplete="off" />
|
||
<button class="galaxy-search-clear" id="galaxySearchClear" title="پاک کردن">
|
||
✕
|
||
</button>
|
||
</div>
|
||
<button class="btn btn-sm" id="galaxyCollapseAll" title="کولاپس همه محصولات">
|
||
⊟ کولاپس همه
|
||
</button>
|
||
<button class="btn btn-sm" id="galaxyExpandAll" title="باز کردن همه محصولات">
|
||
⊞ باز کردن همه
|
||
</button>
|
||
<button class="btn btn-sm" id="galaxyResetView">🎯 بازنشانی</button>
|
||
<button class="btn btn-sm" id="galaxyExportPNG">📷 PNG</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body galaxy-modal-body">
|
||
<div id="galaxyChartContainer" class="galaxy-chart-container"></div>
|
||
<div class="galaxy-search-results" id="galaxySearchResults"></div>
|
||
<div class="galaxy-legend" id="galaxyLegend"></div>
|
||
<div class="galaxy-info-panel" id="galaxyInfoPanel">
|
||
<button class="modal-close" id="galaxyInfoClose" style="
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 8px;
|
||
width: 22px;
|
||
height: 22px;
|
||
font-size: 12px;
|
||
">
|
||
✕
|
||
</button>
|
||
<div id="galaxyInfoContent"></div>
|
||
</div>
|
||
<div class="galaxy-zoom-indicator" id="galaxyZoomIndicator">
|
||
🔍 100%
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="galaxy-stats" id="galaxyStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Executive Dashboard Modal -->
|
||
<div class="modal-overlay" id="executiveDashboardModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">📊 داشبورد مدیریتی</div>
|
||
<div class="galaxy-toolbar">
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="dashboardContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="dashboard-footer-stats" id="dashboardFooterStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dependency Analysis Modal -->
|
||
<div class="modal-overlay" id="dependencyAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔗 تحلیل وابستگیها</div>
|
||
<div class="galaxy-toolbar">
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="dependencyAnalysisContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="dashboard-footer-stats" id="depAnalysisFooterStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Deployment Analysis Modal -->
|
||
<div class="modal-overlay" id="deploymentAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🚀 تحلیل استقرار</div>
|
||
<div class="galaxy-toolbar">
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="deploymentAnalysisContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="dashboard-footer-stats" id="deployAnalysisFooterStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Version Analysis Modal -->
|
||
<div class="modal-overlay" id="versionAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔢 تحلیل نسخه</div>
|
||
<div class="galaxy-toolbar">
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="versionAnalysisContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="dashboard-footer-stats" id="verAnalysisFooterStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Risk Analysis Modal -->
|
||
<div class="modal-overlay" id="riskAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">⚠️ تحلیل ریسک</div>
|
||
<div class="galaxy-toolbar">
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="riskAnalysisContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="dashboard-footer-stats" id="riskAnalysisFooterStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Release Boards Modal -->
|
||
<div class="modal-overlay" id="releaseBoardsModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🚀 بردهای انتشار</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<!-- ✅ Toolbar با جستجو و فیلتر تگ -->
|
||
<div class="rb-toolbar">
|
||
<button class="btn" id="rbEditBoard" title="ویرایش برد فعلی">
|
||
✏️ ویرایش
|
||
</button>
|
||
<button class="btn" id="rbManageTags" title="مدیریت تگها">
|
||
🏷️ تگها
|
||
</button>
|
||
<div class="rb-toolbar-separator"></div>
|
||
<div class="rb-search-box">
|
||
<span style="opacity: 0.7; font-size: 12px">🔍</span>
|
||
<input type="text" id="rbCardSearchInput" placeholder="جستجوی کارتها..." autocomplete="off" />
|
||
<button class="rb-search-clear" id="rbCardSearchClear" title="پاک کردن">
|
||
✕
|
||
</button>
|
||
</div>
|
||
<span class="rb-filter-status" id="rbFilterStatus">📋 <span id="rbFilterCount">0</span>/<span
|
||
id="rbFilterTotal">0</span></span>
|
||
<div class="rb-toolbar-separator"></div>
|
||
<button class="btn" id="rbUndoBtn" title="بازگشت (Ctrl+Z)" disabled>
|
||
↩️
|
||
</button>
|
||
<button class="btn" id="rbRedoBtn" title="از نو (Ctrl+Y)" disabled>
|
||
↪️
|
||
</button>
|
||
</div>
|
||
<div class="rb-tag-filter-bar" id="rbTagFilterBar">
|
||
<span class="rb-tag-filter-label">🏷️ فیلتر تگ:</span>
|
||
<div id="rbTagFilterContainer" style="
|
||
display: flex;
|
||
gap: 5px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
"></div>
|
||
<button class="rb-filter-clear-btn" id="rbClearTagFilter">
|
||
✕ حذف فیلتر
|
||
</button>
|
||
</div>
|
||
<!-- Tab Bar -->
|
||
<div class="rb-tabs-bar" id="rbTabsBar"></div>
|
||
<!-- Board Area -->
|
||
<div class="modal-body" style="
|
||
padding: 0;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
">
|
||
<div class="rb-board-area" id="rbBoardArea">
|
||
<div class="rb-empty-state" id="rbEmptyState">
|
||
<div class="rb-empty-state-icon">📋</div>
|
||
<h2 style="color: var(--primary); margin-bottom: 10px">
|
||
هیچ بردی وجود ندارد
|
||
</h2>
|
||
<p style="margin-bottom: 20px">
|
||
برای شروع، یک برد جدید ایجاد کنید
|
||
</p>
|
||
<button class="btn btn-accent" onclick="ReleaseBoardsManager.openNewBoardModal()">
|
||
➕ ایجاد اولین برد
|
||
</button>
|
||
</div>
|
||
<div class="rb-board-wrapper" id="rbBoardWrapper" style="display: none"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Footer -->
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div class="dashboard-footer-stats" id="rbFooterStats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: New/Edit Board -->
|
||
<div class="modal-overlay" id="rbBoardFormModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">
|
||
📋 <span id="rbBoardFormTitle">ایجاد برد جدید</span>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان برد <span class="required">*</span></label><input type="text"
|
||
class="form-input" id="rbBoardTitle" placeholder="مثلاً: اسپرینت جاری" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label><textarea class="form-textarea" id="rbBoardDesc"
|
||
placeholder="توضیحات برد..."></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">🎨 رنگ پسزمینه</label><input type="color" class="form-input" id="rbBoardBgColor"
|
||
value="#e2e8f0" style="height: 40px" />
|
||
</div>
|
||
<div class="form-group" id="rbDefaultColumnsGroup">
|
||
<label class="form-label">📑 ستونهای اولیه</label>
|
||
<div class="rb-default-columns-switch">
|
||
<label class="rb-switch-label">
|
||
<div class="rb-switch-info">
|
||
<div class="rb-switch-title">ایجاد با ستونهای پیشفرض</div>
|
||
<div class="rb-switch-desc">
|
||
۵ ستون استاندارد کانبان به صورت خودکار ایجاد میشود
|
||
</div>
|
||
</div>
|
||
<label class="switch"><input type="checkbox" id="rbUseDefaultColumns" checked /><span
|
||
class="slider"></span></label>
|
||
</label>
|
||
<div class="rb-default-columns-preview" id="rbDefaultColumnsPreview">
|
||
<div class="rb-preview-column" style="background: #6b7208; color: #b4cbf9">
|
||
📥 بک لاگ
|
||
</div>
|
||
<div class="rb-preview-column" style="background: #393e65; color: #b4cbf9">
|
||
📋 برای انجام
|
||
</div>
|
||
<div class="rb-preview-column" style="background: #453111; color: #b4cbf9">
|
||
🔨 در حال انجام
|
||
</div>
|
||
<div class="rb-preview-column" style="background: #542881; color: #b4cbf9">
|
||
🔍 برای تست
|
||
</div>
|
||
<div class="rb-preview-column" style="background: #087224; color: #d8d96d">
|
||
✅ انجام شده
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="rbSaveBoardBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: New/Edit Column -->
|
||
<div class="modal-overlay" id="rbColumnFormModal">
|
||
<div class="modal" style="max-width: 500px">
|
||
<div class="modal-header">
|
||
<div class="modal-title">
|
||
📊 <span id="rbColumnFormTitle">ایجاد ستون جدید</span>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان ستون <span class="required">*</span></label><input type="text"
|
||
class="form-input" id="rbColumnTitle" placeholder="مثلاً: در حال انجام" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">🎨 رنگ پسزمینه</label><input type="color" class="form-input" id="rbColumnBgColor"
|
||
value="#edf2f7" style="height: 40px" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">🖋️ رنگ متن</label><input type="color" class="form-input" id="rbColumnTextColor"
|
||
value="#1a202c" style="height: 40px" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="rbSaveColumnBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: New/Edit Card -->
|
||
<div class="modal-overlay" id="rbCardFormModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">
|
||
📝 <span id="rbCardFormTitle">ایجاد کارت جدید</span>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">عنوان کارت <span class="required">*</span></label>
|
||
<input type="text" class="form-input" id="rbCardTitle" placeholder="عنوان وظیفه..." />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات <span class="required">*</span></label>
|
||
<textarea class="form-textarea" id="rbCardDesc" placeholder="توضیحات کامل..."></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">📅 تاریخ تخمینی</label>
|
||
<input type="datetime-local" class="form-input" id="rbCardDate" dir="ltr" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">🎨 رنگ کارت</label>
|
||
<input type="color" class="form-input" id="rbCardBgColor" value="#ffffff" style="height: 40px" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">👥 افراد مسئول</label>
|
||
<div id="rbAssigneesContainer" style="
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
min-height: 42px;
|
||
background: var(--border-light);
|
||
">
|
||
</div>
|
||
<div style="display: flex; gap: 6px; margin-top: 6px">
|
||
<input type="text" class="form-input" id="rbNewAssigneeName" placeholder="نام فرد مسئول..." />
|
||
<button class="btn btn-sm" onclick="ReleaseBoardsManager.addAssignee()">➕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">🔗 وابستگی به کارتهای دیگر</label>
|
||
<div id="rbDependenciesContainer" style="
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
min-height: 42px;
|
||
background: var(--border-light);
|
||
">
|
||
</div>
|
||
<div style="display: flex; gap: 6px; margin-top: 6px">
|
||
<select class="form-select" id="rbDependencySelect">
|
||
<option value="">انتخاب کارت برای وابستگی...</option>
|
||
</select>
|
||
<button class="btn btn-sm" onclick="ReleaseBoardsManager.addDependency()">➕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">🏷️ برچسبها</label>
|
||
<div id="rbCardTagsContainer" style="
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
min-height: 50px;
|
||
background: var(--border-light);
|
||
">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">✅ چکلیست</label>
|
||
<div id="rbChecklistContainer" style="
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 8px;
|
||
background: var(--border-light);
|
||
max-height: 180px;
|
||
overflow-y: auto;
|
||
">
|
||
</div>
|
||
<div style="display: flex; gap: 6px; margin-top: 6px">
|
||
<input type="text" class="form-input" id="rbNewChecklistItem" placeholder="ریزوظیفه جدید..." />
|
||
<button class="btn btn-sm" onclick="ReleaseBoardsManager.addChecklistItem()">➕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="rbCardHistoryGroup" style="display: none">
|
||
<label class="form-label">📜 تاریخچه عملیات</label>
|
||
<div id="rbCardHistoryContainer" style="
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 8px;
|
||
background: var(--border-light);
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
font-size: 11px;
|
||
">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="rbSaveCardBtn">💾 ذخیره</button>
|
||
<button class="btn btn-danger" id="rbDeleteCardBtn" style="display: none">🗑️ حذف</button>
|
||
<button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Tag Manager -->
|
||
<div class="modal-overlay" id="rbTagManagerModal">
|
||
<div class="modal" style="max-width: 550px">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🏷️ مدیریت تگهای بردها</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="rb-tag-add-form">
|
||
<div class="form-row" style="gap: 8px; align-items: flex-end">
|
||
<div class="form-group" style="flex: 1; margin-bottom: 0">
|
||
<label class="form-label">نام تگ</label><input type="text" class="form-input" id="rbNewTagName"
|
||
placeholder="مثلاً: فوری، باگ، ویژگی..." />
|
||
</div>
|
||
<div class="form-group" style="width: 80px; margin-bottom: 0">
|
||
<label class="form-label">رنگ</label><input type="color" class="form-input" id="rbNewTagColor"
|
||
value="#4299e1" style="height: 38px; padding: 2px" />
|
||
</div>
|
||
<button class="btn btn-accent" id="rbAddTagBtn" style="height: 38px; white-space: nowrap">
|
||
➕ افزودن
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="rb-tag-list-header">
|
||
<span>تگهای موجود</span><span class="rb-tag-count" id="rbTagCount">0</span>
|
||
</div>
|
||
<div class="rb-tag-list" id="rbTagList"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
<div style="font-size: 11px; color: var(--text-muted)">
|
||
💡 تغییرات تگها به صورت خودکار در تمام کارتها اعمال میشود
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Edit Tag -->
|
||
<div class="modal-overlay" id="rbEditTagModal">
|
||
<div class="modal" style="max-width: 420px">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش تگ</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">نام تگ <span class="required">*</span></label><input type="text" class="form-input"
|
||
id="rbEditTagName" placeholder="نام تگ..." />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">🎨 رنگ</label><input type="color" class="form-input" id="rbEditTagColor"
|
||
value="#4299e1" style="height: 40px" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">پیشنمایش</label>
|
||
<div style="
|
||
padding: 10px;
|
||
background: var(--border-light);
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
">
|
||
<span class="card-tag" id="rbEditTagPreview" style="font-size: 13px; padding: 4px 14px">نمونه</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="rbSaveTagBtn">💾 ذخیره</button><button class="btn btn-ghost"
|
||
data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Autosave Recovery -->
|
||
<div class="modal-overlay" id="rbAutosaveModal">
|
||
<div class="modal" style="max-width: 550px">
|
||
<div class="modal-header">
|
||
<div class="modal-title">💾 بازیابی خودکار دادهها</div>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div style="text-align: center; padding: 24px 16px">
|
||
<div style="font-size: 48px; margin-bottom: 16px">💾</div>
|
||
<div style="
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 8px;
|
||
">
|
||
دادههایی در حافظه موقت وجود دارند
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-muted)">
|
||
آیا مایل به بازیابی آنها هستید؟
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer" style="justify-content: space-between">
|
||
<button class="btn btn-accent" id="rbRecoverAutosaveBtn">
|
||
💾 بازیابی
|
||
</button>
|
||
<button class="btn btn-danger" id="rbDiscardAutosaveBtn">
|
||
🗑️ نادیده گرفتن
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Auto-save Indicator -->
|
||
<div id="rbAutosaveIndicator" style="
|
||
position: fixed;
|
||
bottom: 50px;
|
||
left: 20px;
|
||
background: var(--panel-bg);
|
||
color: var(--text);
|
||
padding: 6px 12px;
|
||
border-radius: 20px;
|
||
font-size: 11px;
|
||
box-shadow: var(--shadow);
|
||
border: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
z-index: 1500;
|
||
pointer-events: none;
|
||
">
|
||
<div style="
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--success);
|
||
animation: rbPulse 2s infinite;
|
||
"></div>
|
||
<span id="rbAutosaveText">ذخیره خودکار انجام شد</span>
|
||
</div>
|
||
<style>
|
||
@keyframes rbPulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.4;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
<div class="galaxy-tooltip" id="galaxyTooltip"></div>
|
||
<div class="d3-tooltip" id="d3Tooltip"></div>
|
||
|
||
<!-- Scripts -->
|
||
<script>
|
||
/* ==================== DATA ==================== */
|
||
const projectData = {
|
||
projectId: "550e8400-e29b-41d4-a716-446655440000",
|
||
projectName: "xReleaseAssistant",
|
||
projectTitle: "دستیار انتشار",
|
||
description: "مدیریت نسخهها و نیازمندیهای محصولات",
|
||
version: "1.1.0",
|
||
developer: "هادی خزاعی اصل",
|
||
company: "رایان پرداز کاوش",
|
||
metadata: {
|
||
createdAt: "2026-09-13T00:00:00Z",
|
||
updatedAt: "2026-09-18T00:00:00Z",
|
||
tags: ["release-management"],
|
||
customFields: {},
|
||
},
|
||
webhook: { url: "", clientId: "", enabled: false },
|
||
toolGroups: [
|
||
{
|
||
id: "product-management",
|
||
title: "پرونده",
|
||
icon: "📁",
|
||
description: "مدیریت چرخه حیات محصول",
|
||
tools: [
|
||
{
|
||
id: "clear-all",
|
||
title: "پاکسازی",
|
||
icon: "🗑️",
|
||
action: "clearAll",
|
||
description: "پاکسازی کامل",
|
||
},
|
||
{
|
||
id: "save-product",
|
||
title: "ذخیره",
|
||
icon: "💾",
|
||
action: "saveProduct",
|
||
description: "دانلود دادهها",
|
||
},
|
||
{
|
||
id: "load-product",
|
||
title: "بارگذاری",
|
||
icon: "📂",
|
||
action: "loadProduct",
|
||
description: "بارگذاری از فایل",
|
||
},
|
||
{
|
||
id: "product-settings",
|
||
title: "تنظیمات",
|
||
icon: "⚙️",
|
||
action: "openProductSettings",
|
||
description: "پیکربندی",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "boards",
|
||
title: "بردها",
|
||
icon: "📋",
|
||
description: "مدیریت بردهای انتشار",
|
||
tools: [
|
||
{
|
||
id: "release-boards",
|
||
title: "بردهای انتشار",
|
||
icon: "🚀",
|
||
action: "showReleaseBoards",
|
||
description: "مشاهده و مدیریت بردهای انتشار",
|
||
},
|
||
{
|
||
id: "tag-manager",
|
||
title: "مدیریت تگها",
|
||
icon: "🏷️",
|
||
action: "showTagManager",
|
||
description: "مدیریت تگهای بردها",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "reports",
|
||
title: "گزارشها",
|
||
icon: "📊",
|
||
description: "تحلیل و گزارشگیری",
|
||
tools: [
|
||
{
|
||
id: "executive-dashboard",
|
||
title: "داشبورد مدیریتی",
|
||
icon: "📊",
|
||
action: "showExecutiveDashboard",
|
||
description: "نمای کلی با نمودارها",
|
||
},
|
||
{
|
||
id: "dependency-analysis",
|
||
title: "تحلیل وابستگیها",
|
||
icon: "🔗",
|
||
action: "showDependencyAnalysis",
|
||
description: "تحلیل وابستگیها",
|
||
},
|
||
{
|
||
id: "deployment-analysis",
|
||
title: "تحلیل استقرار",
|
||
icon: "🚀",
|
||
action: "showDeploymentAnalysis",
|
||
description: "تحلیل استقرارها",
|
||
},
|
||
{
|
||
id: "version-analysis",
|
||
title: "تحلیل نسخه",
|
||
icon: "🔢",
|
||
action: "showVersionAnalysis",
|
||
description: "تحلیل نسخهها",
|
||
},
|
||
{
|
||
id: "risk-analysis",
|
||
title: "تحلیل ریسک",
|
||
icon: "⚠️",
|
||
action: "showRiskAnalysis",
|
||
description: "تحلیل ریسکها",
|
||
},
|
||
{
|
||
id: "galaxy-chart",
|
||
title: "نمودار کهکشانی",
|
||
icon: "🌌",
|
||
action: "showGalaxyChart",
|
||
description: "نمودار کهکشانی",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
appData: {
|
||
environments: [],
|
||
products: [],
|
||
boards: [],
|
||
boardTags: [],
|
||
},
|
||
};
|
||
const VALID_PROJECT_TYPES = [
|
||
{ value: "backend", label: "بکاند (Backend)", icon: "🖥️" },
|
||
{ value: "frontend", label: "فرانتاند (Frontend)", icon: "🌐" },
|
||
{ value: "service", label: "میکروسرویس (Service)", icon: "⚙️" },
|
||
{ value: "database", label: "پایگاه داده (Database)", icon: "🗄️" },
|
||
{ value: "mobile", label: "موبایل (Mobile)", icon: "📱" },
|
||
{
|
||
value: "infrastructure",
|
||
label: "زیرساخت (Infrastructure)",
|
||
icon: "🏗️",
|
||
},
|
||
];
|
||
/* ==================== HELPERS ==================== */
|
||
function validateAppDataStructure(d) {
|
||
return (
|
||
d &&
|
||
typeof d === "object" &&
|
||
Array.isArray(d.environments) &&
|
||
Array.isArray(d.products)
|
||
);
|
||
}
|
||
function populateProjectTypeDropdowns() {
|
||
["newProjectType", "editProjType"].forEach((id) => {
|
||
const s = document.getElementById(id);
|
||
if (!s) return;
|
||
const cv = s.value;
|
||
s.innerHTML =
|
||
'<option value="">انتخاب کنید...</option>' +
|
||
VALID_PROJECT_TYPES.map(
|
||
(t) => `<option value="${t.value}">${t.icon} ${t.label}</option>`,
|
||
).join("");
|
||
if (cv) s.value = cv;
|
||
});
|
||
}
|
||
function showToast(msg, type = "info", dur = 3000) {
|
||
const c = document.getElementById("toastContainer");
|
||
const t = document.createElement("div");
|
||
t.className = `toast ${type}`;
|
||
const ic = { success: "✅", error: "❌", warning: "⚠️", info: "ℹ️" };
|
||
t.innerHTML = `<span>${ic[type] || "ℹ️"}</span><span>${msg}</span>`;
|
||
c.appendChild(t);
|
||
setTimeout(() => {
|
||
t.style.opacity = "0";
|
||
setTimeout(() => t.remove(), 300);
|
||
}, dur);
|
||
}
|
||
function showD3Tooltip(e, text) {
|
||
let tt = document.getElementById("d3Tooltip");
|
||
if (!tt) return;
|
||
if (!text) {
|
||
hideD3Tooltip();
|
||
return;
|
||
}
|
||
tt.textContent = text;
|
||
tt.classList.add("visible");
|
||
|
||
const ttRect = tt.getBoundingClientRect();
|
||
const padding = 12;
|
||
let x = e.clientX + padding;
|
||
let y = e.clientY + padding;
|
||
|
||
if (x + ttRect.width > window.innerWidth) {
|
||
x = e.clientX - ttRect.width - padding;
|
||
}
|
||
|
||
if (y + ttRect.height > window.innerHeight) {
|
||
y = e.clientY - ttRect.height - padding;
|
||
}
|
||
|
||
tt.style.left = x + "px";
|
||
tt.style.top = y + "px";
|
||
}
|
||
function hideD3Tooltip() {
|
||
const tt = document.getElementById("d3Tooltip");
|
||
if (tt) tt.classList.remove("visible");
|
||
}
|
||
function refreshUI() {
|
||
ToolbarManager.updateStats();
|
||
BoardRenderer.render();
|
||
}
|
||
/* ==================== MODAL MANAGER ==================== */
|
||
const ModalManager = {
|
||
stack: [],
|
||
open(id) {
|
||
const m = document.getElementById(id);
|
||
if (!m) return;
|
||
this.stack = this.stack.filter((x) => x !== id);
|
||
this.stack.push(id);
|
||
this.stack.forEach((mid, i) => {
|
||
const el = document.getElementById(mid);
|
||
if (!el) return;
|
||
if (mid === id) el.classList.add("active");
|
||
el.style.zIndex = 1000 + i + 1;
|
||
});
|
||
},
|
||
close(id) {
|
||
const m = document.getElementById(id);
|
||
if (!m) return;
|
||
m.classList.remove("active");
|
||
m.style.zIndex = "";
|
||
this.stack = this.stack.filter((x) => x !== id);
|
||
this.stack.forEach((mid, i) => {
|
||
const el = document.getElementById(mid);
|
||
if (el) el.style.zIndex = 1000 + i + 1;
|
||
});
|
||
},
|
||
closeTop() {
|
||
if (this.stack.length > 0)
|
||
this.close(this.stack[this.stack.length - 1]);
|
||
},
|
||
init() {
|
||
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const m = e.target.closest(".modal-overlay");
|
||
if (m && m.id) this.close(m.id);
|
||
});
|
||
});
|
||
document.querySelectorAll(".modal-overlay").forEach((ov) => {
|
||
ov.addEventListener("click", (e) => {
|
||
if (e.target === ov && ov.id) this.close(ov.id);
|
||
});
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
this.closeTop();
|
||
}
|
||
});
|
||
},
|
||
};
|
||
/* ==================== TOOLBAR MANAGER ==================== */
|
||
const ToolbarManager = {
|
||
currentFilters: { query: "", typeFilter: "", envFilter: "", ownerFilter: "" },
|
||
init() {
|
||
this.populateEnvironmentFilter();
|
||
this.populateOwnerFilter();
|
||
this.bindEvents();
|
||
this.updateStats();
|
||
},
|
||
populateEnvironmentFilter() {
|
||
const s = document.getElementById("filterEnvironment");
|
||
if (!s) return;
|
||
s.innerHTML =
|
||
'<option value="">همه محیطها</option>' +
|
||
projectData.appData.environments
|
||
.map((e) => `<option value="${e.id}">${e.name}</option>`)
|
||
.join("");
|
||
},
|
||
populateOwnerFilter() {
|
||
const s = document.getElementById("filterOwner");
|
||
if (!s) return;
|
||
const owners = new Set();
|
||
(projectData.appData.products || []).forEach((p) => {
|
||
const owner = p.metadata?.owner?.trim();
|
||
if (owner) owners.add(owner);
|
||
});
|
||
const sortedOwners = Array.from(owners).sort((a, b) =>
|
||
a.localeCompare(b, "fa"),
|
||
);
|
||
s.innerHTML =
|
||
'<option value="">همه مالکان</option>' +
|
||
sortedOwners
|
||
.map((o) => `<option value="${o}">${o}</option>`)
|
||
.join("");
|
||
},
|
||
bindEvents() {
|
||
const si = document.getElementById("toolbarSearch"),
|
||
ft = document.getElementById("filterType"),
|
||
fe = document.getElementById("filterEnvironment"),
|
||
fo = document.getElementById("filterOwner");
|
||
if (si)
|
||
si.addEventListener("input", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
if (ft)
|
||
ft.addEventListener("change", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
if (fe)
|
||
fe.addEventListener("change", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
if (fo)
|
||
fo.addEventListener("change", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
},
|
||
applyFilters() {
|
||
this.currentFilters = {
|
||
query: (document.getElementById("toolbarSearch")?.value || "")
|
||
.trim()
|
||
.toLowerCase(),
|
||
typeFilter: document.getElementById("filterType")?.value || "",
|
||
envFilter: document.getElementById("filterEnvironment")?.value || "",
|
||
ownerFilter: document.getElementById("filterOwner")?.value || "",
|
||
};
|
||
this.updateStats();
|
||
},
|
||
updateStats() {
|
||
const ps = projectData.appData.products || [];
|
||
let tp = 0,
|
||
td = 0;
|
||
ps.forEach((p) => {
|
||
(p.projects || []).forEach((pr) => {
|
||
tp++;
|
||
td += (pr.dependencies || []).length;
|
||
});
|
||
});
|
||
const s = (id, v) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = v;
|
||
};
|
||
s("statTotal", ps.length + tp);
|
||
s("statProjects", ps.length);
|
||
s("statSubProjects", tp);
|
||
s("statDependencies", td);
|
||
},
|
||
shouldShowProduct(product) {
|
||
const f = this.currentFilters;
|
||
if (!f.query && !f.typeFilter && !f.envFilter && !f.ownerFilter) return true;
|
||
|
||
if (f.ownerFilter) {
|
||
const prodOwner = product.metadata?.owner?.trim();
|
||
if (prodOwner !== f.ownerFilter) return false;
|
||
}
|
||
|
||
if (!f.query && !f.typeFilter && !f.envFilter) return true;
|
||
|
||
return (product.projects || []).some((pr) => {
|
||
const mq =
|
||
!f.query ||
|
||
pr.title.toLowerCase().includes(f.query) ||
|
||
product.title.toLowerCase().includes(f.query);
|
||
const mt = !f.typeFilter || pr.type === f.typeFilter;
|
||
const me =
|
||
!f.envFilter ||
|
||
(pr.deployments || [])
|
||
.filter((d) => d.status === "active")
|
||
.some((d) => d.environmentId === f.envFilter);
|
||
|
||
return mq && mt && me;
|
||
});
|
||
},
|
||
};
|
||
/* ==================== HISTORY MANAGER ==================== */
|
||
const HistoryManager = {
|
||
undoStack: [],
|
||
redoStack: [],
|
||
maxSize: 50,
|
||
isUndoRedoInProgress: false,
|
||
_debounceTimer: null,
|
||
_pendingState: null,
|
||
pushState() {
|
||
if (this.isUndoRedoInProgress) return;
|
||
this._pendingState = projectData.appData;
|
||
if (this._debounceTimer) clearTimeout(this._debounceTimer);
|
||
this._debounceTimer = setTimeout(() => {
|
||
this._commitState();
|
||
}, 100);
|
||
},
|
||
_commitState() {
|
||
if (!this._pendingState) return;
|
||
let snapshot;
|
||
if (typeof structuredClone === "function")
|
||
snapshot = structuredClone(this._pendingState);
|
||
else snapshot = JSON.parse(JSON.stringify(this._pendingState));
|
||
this.undoStack.push(snapshot);
|
||
if (this.undoStack.length > this.maxSize) this.undoStack.shift();
|
||
this.redoStack = [];
|
||
this._pendingState = null;
|
||
this.updateButtons();
|
||
},
|
||
undo() {
|
||
if (this.undoStack.length === 0) {
|
||
showToast("هیچ تغییری نیست", "warning");
|
||
return;
|
||
}
|
||
if (this._pendingState) this._commitState();
|
||
this.redoStack.push(
|
||
structuredClone
|
||
? structuredClone(projectData.appData)
|
||
: JSON.parse(JSON.stringify(projectData.appData)),
|
||
);
|
||
this.isUndoRedoInProgress = true;
|
||
projectData.appData = this.undoStack.pop();
|
||
this.isUndoRedoInProgress = false;
|
||
this.updateUI();
|
||
this.updateButtons();
|
||
showToast("↩️ بازگشت", "success");
|
||
},
|
||
redo() {
|
||
if (this.redoStack.length === 0) {
|
||
showToast("هیچ تغییری نیست", "warning");
|
||
return;
|
||
}
|
||
if (this._pendingState) this._commitState();
|
||
this.undoStack.push(
|
||
structuredClone
|
||
? structuredClone(projectData.appData)
|
||
: JSON.parse(JSON.stringify(projectData.appData)),
|
||
);
|
||
this.isUndoRedoInProgress = true;
|
||
projectData.appData = this.redoStack.pop();
|
||
this.isUndoRedoInProgress = false;
|
||
this.updateUI();
|
||
this.updateButtons();
|
||
showToast("↪️ باز انجام", "success");
|
||
},
|
||
updateUI() {
|
||
refreshUI();
|
||
if (ProductManager.currentDetailProductId)
|
||
ProductManager.showDetail(ProductManager.currentDetailProductId);
|
||
},
|
||
updateButtons() {
|
||
const u = document.getElementById("undoBtn"),
|
||
r = document.getElementById("redoBtn");
|
||
if (u) u.disabled = this.undoStack.length === 0;
|
||
if (r) r.disabled = this.redoStack.length === 0;
|
||
},
|
||
init() {
|
||
this.pushState();
|
||
document
|
||
.getElementById("undoBtn")
|
||
.addEventListener("click", () => this.undo());
|
||
document
|
||
.getElementById("redoBtn")
|
||
.addEventListener("click", () => this.redo());
|
||
document.addEventListener("keydown", (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.undo();
|
||
}
|
||
if (
|
||
(e.ctrlKey || e.metaKey) &&
|
||
(e.key === "y" || (e.key === "z" && e.shiftKey))
|
||
) {
|
||
e.preventDefault();
|
||
this.redo();
|
||
}
|
||
});
|
||
},
|
||
};
|
||
/* ==================== SECTION COLLAPSE ==================== */
|
||
const SectionCollapseManager = {
|
||
storageKey: "boardSectionCollapseState",
|
||
getState() {
|
||
try {
|
||
const s = localStorage.getItem(this.storageKey);
|
||
return s
|
||
? JSON.parse(s)
|
||
: { dashboard: false, products: false, environments: false };
|
||
} catch {
|
||
return { dashboard: false, products: false, environments: false };
|
||
}
|
||
},
|
||
saveState(s) {
|
||
try {
|
||
localStorage.setItem(this.storageKey, JSON.stringify(s));
|
||
} catch (e) { }
|
||
},
|
||
toggle(id) {
|
||
const s = this.getState();
|
||
s[id] = !s[id];
|
||
this.saveState(s);
|
||
const sec = document.getElementById(`section-${id}`);
|
||
if (sec) sec.classList.toggle("collapsed", s[id]);
|
||
return s[id];
|
||
},
|
||
init() {
|
||
const s = this.getState();
|
||
Object.keys(s).forEach((id) => {
|
||
const sec = document.getElementById(`section-${id}`);
|
||
if (sec && s[id]) sec.classList.add("collapsed");
|
||
});
|
||
document
|
||
.getElementById("boardArea")
|
||
.addEventListener("click", (e) => {
|
||
const ht = e.target.closest(".board-header-title");
|
||
if (ht) {
|
||
const sec = ht.closest(".board-section");
|
||
if (sec) this.toggle(sec.id.replace("section-", ""));
|
||
}
|
||
});
|
||
},
|
||
};
|
||
/* ==================== DASHBOARD SECTION ==================== */
|
||
const DashboardSectionRenderer = {
|
||
getSectionHTML() {
|
||
const d = this.getData();
|
||
let h = `<div class="board-section" id="section-dashboard">`;
|
||
h += `<div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📊 داشبورد مدیریتی</div><div class="board-header-actions"><button class="btn btn-accent" onclick="DashboardManager.open()">📊 مشاهده کامل</button></div></div>`;
|
||
h += `<div class="section-content">`;
|
||
h += '<div class="dashboard-kpi-row" style="margin-bottom:16px">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
|
||
h += "</div>";
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژهها</div><div class="dashboard-chart-body" id="mainDashTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="mainDashEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژهها</div><div class="dashboard-chart-body" id="mainDashStatusChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژهها به تفکیک محصول</div><div class="dashboard-chart-body" id="mainDashProductChart"></div></div>';
|
||
h += "</div>";
|
||
h += "</div></div>";
|
||
return h;
|
||
},
|
||
getData() {
|
||
const ps = projectData.appData.products || [];
|
||
let tp = 0,
|
||
td = 0,
|
||
tdp = 0;
|
||
const tc = {},
|
||
edc = {},
|
||
ppc = [];
|
||
let pwd = 0,
|
||
pwod = 0;
|
||
ps.forEach((prod) => {
|
||
const projects = prod.projects || [];
|
||
ppc.push({ title: prod.title, count: projects.length });
|
||
projects.forEach((pr) => {
|
||
tp++;
|
||
td += (pr.dependencies || []).length;
|
||
const active = (pr.deployments || []).filter(
|
||
(d) => d.status === "active",
|
||
);
|
||
tdp += active.length;
|
||
if (active.length > 0) pwd++;
|
||
else pwod++;
|
||
tc[pr.type] = (tc[pr.type] || 0) + 1;
|
||
active.forEach((d) => {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
edc[envName] = (edc[envName] || 0) + 1;
|
||
});
|
||
});
|
||
});
|
||
return {
|
||
products: ps.length,
|
||
projects: tp,
|
||
deps: td,
|
||
deploys: tdp,
|
||
environments: projectData.appData.environments.length,
|
||
tc,
|
||
edc,
|
||
ppc,
|
||
pwd,
|
||
pwod,
|
||
};
|
||
},
|
||
renderCharts() {
|
||
const d = this.getData();
|
||
this.renderDonut(
|
||
"mainDashTypeChart",
|
||
Object.entries(d.tc).map(([k, v]) => ({
|
||
label: this.getTypeLabel(k),
|
||
value: v,
|
||
color: this.getTypeColor(k),
|
||
})),
|
||
);
|
||
this.renderBar(
|
||
"mainDashEnvChart",
|
||
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
|
||
"#38a169",
|
||
);
|
||
this.renderDonut("mainDashStatusChart", [
|
||
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
|
||
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
|
||
]);
|
||
this.renderBar(
|
||
"mainDashProductChart",
|
||
d.ppc.map((p) => ({
|
||
label:
|
||
p.title.length > 15
|
||
? p.title.substring(0, 15) + "..."
|
||
: p.title,
|
||
value: p.count,
|
||
})),
|
||
"#3182ce",
|
||
);
|
||
},
|
||
renderDonut(cid, data) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 240,
|
||
hgt = 200,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((d) => d.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 6);
|
||
const total = data.reduce((s, d) => s + d.value, 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (d) => d.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(d));
|
||
showD3Tooltip(
|
||
e,
|
||
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "20px")
|
||
.attr("font-weight", "800")
|
||
.text(total);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.3em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.text("پروژه");
|
||
},
|
||
renderBar(cid, data, color) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 200,
|
||
m = { top: 20, right: 15, bottom: 40, left: 30 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(data.map((d) => d.label))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (d) => d.value) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-20)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", (d) => x(d.label))
|
||
.attr("y", (d) => y(d.value))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (d) => ih - y(d.value))
|
||
.attr("fill", color)
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d.label}: ${d.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", color);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (d) => x(d.label) + x.bandwidth() / 2)
|
||
.attr("y", (d) => y(d.value) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.value);
|
||
},
|
||
getTypeLabel(t) {
|
||
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
|
||
return i ? i.label.split(" ")[0] : t;
|
||
},
|
||
getTypeColor(t) {
|
||
const c = {
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
};
|
||
return c[t] || "#718096";
|
||
},
|
||
};
|
||
/* ==================== ENVIRONMENT RENDERER ==================== */
|
||
const EnvironmentRenderer = {
|
||
getSectionHTML() {
|
||
const envs = projectData.appData.environments || [];
|
||
let h = `<div class="board-section" id="section-environments"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>🌍 محیطهای عملیاتی (${envs.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="EnvironmentManager.openAddModal()">➕ محیط جدید</button></div></div><div class="section-content">`;
|
||
if (envs.length === 0) {
|
||
h +=
|
||
'<div class="board-empty"><div class="board-empty-icon">🌍</div><h2>هیچ محیطی نیست</h2></div>';
|
||
} else {
|
||
h += '<div class="env-card-grid">';
|
||
envs.forEach((env) => {
|
||
h += `<div class="env-card-item"><div class="env-card-preview" style="background-color:${env.backgroundColor || "#38a169"};color:${env.textColor || "#fff"}">${env.name}</div><div class="env-card-body"><div class="env-card-name">${env.name}</div><div class="env-card-desc">${env.description || ""}</div></div><div class="env-card-footer"><button class="btn btn-ghost btn-sm" onclick="EnvironmentManager.openEditModal('${env.id}')">✏️ ویرایش</button><button class="btn btn-danger btn-sm" onclick="EnvironmentManager.remove('${env.id}')">🗑️ حذف</button></div></div>`;
|
||
});
|
||
h += "</div>";
|
||
}
|
||
h += "</div></div>";
|
||
return h;
|
||
},
|
||
};
|
||
/* ==================== BOARD RENDERER ==================== */
|
||
const BoardRenderer = {
|
||
render() {
|
||
const board = document.getElementById("boardArea");
|
||
const products = projectData.appData.products || [];
|
||
const fp = products.filter((p) =>
|
||
ToolbarManager.shouldShowProduct(p),
|
||
);
|
||
let h = "";
|
||
h += DashboardSectionRenderer.getSectionHTML();
|
||
h += `<div class="board-section" id="section-products"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📦 محصولات (${fp.length} از ${products.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="ProductManager.openAddProductModal()">➕ محصول جدید</button></div></div><div class="section-content">`;
|
||
if (products.length === 0) {
|
||
h +=
|
||
'<div class="board-empty"><div class="board-empty-icon">📦</div><h2>هیچ محصولی نیست</h2></div>';
|
||
} else if (fp.length === 0) {
|
||
h += '<div class="no-results">🔍 نتیجهای یافت نشد</div>';
|
||
} else {
|
||
h += '<div class="projects-grid">';
|
||
fp.forEach((prod) => {
|
||
const projects = prod.projects || [];
|
||
const tc = {};
|
||
projects.forEach((pr) => {
|
||
tc[pr.type] = (tc[pr.type] || 0) + 1;
|
||
});
|
||
const td = projects.reduce(
|
||
(s, pr) => s + (pr.dependencies || []).length,
|
||
0,
|
||
);
|
||
const tdp = projects.reduce(
|
||
(s, pr) =>
|
||
s +
|
||
(pr.deployments || []).filter((d) => d.status === "active")
|
||
.length,
|
||
0,
|
||
);
|
||
h += `<div class="project-card"><div class="project-card-header"><div class="project-card-title-row"><span style="font-size:22px">📦</span><div class="project-card-title">${prod.title}</div></div><div class="project-card-desc">${prod.description || ""}</div></div><div class="project-card-body"><div class="project-stat-row"><span>🌿 پروژهها:</span><span class="project-stat-value">${projects.length}</span></div><div class="project-stat-row"><span>🔗 وابستگیها:</span><span class="project-stat-value">${td}</span></div><div class="project-stat-row"><span>🚀 استقرارها:</span><span class="project-stat-value">${tdp}</span></div>${Object.keys(tc).length > 0
|
||
? `<div class="project-type-badges">${Object.entries(tc)
|
||
.map(([type, count]) => {
|
||
const ti = VALID_PROJECT_TYPES.find(
|
||
(t) => t.value === type,
|
||
) || { label: type };
|
||
return `<span class="type-badge type-${type}">${ti.label}: ${count}</span>`;
|
||
})
|
||
.join("")}</div>`
|
||
: ""
|
||
}</div><div class="project-card-footer"><div class="card-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.showDetail('${prod.id}')">👁️ جزئیات</button></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeProduct('${prod.id}')">🗑️</button></div></div>`;
|
||
});
|
||
h += "</div>";
|
||
}
|
||
h += "</div></div>";
|
||
h += EnvironmentRenderer.getSectionHTML();
|
||
board.innerHTML = h;
|
||
const st = SectionCollapseManager.getState();
|
||
Object.keys(st).forEach((id) => {
|
||
const sec = document.getElementById(`section-${id}`);
|
||
if (sec && st[id]) sec.classList.add("collapsed");
|
||
});
|
||
setTimeout(() => {
|
||
DashboardSectionRenderer.renderCharts();
|
||
}, 100);
|
||
},
|
||
};
|
||
/* ==================== ACTION REGISTRY ==================== */
|
||
const ActionRegistry = {
|
||
openProductSettings() {
|
||
openSettingsModal();
|
||
},
|
||
saveProduct() {
|
||
projectData.metadata.updatedAt = new Date().toISOString();
|
||
const js = JSON.stringify(projectData.appData, null, 2);
|
||
const b = new Blob([js], { type: "application/json;charset=utf-8" });
|
||
const u = URL.createObjectURL(b);
|
||
const a = document.createElement("a");
|
||
a.href = u;
|
||
a.download = `${projectData.projectName}_v${projectData.version}_${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(u);
|
||
showToast("💾 ذخیره شد", "success");
|
||
},
|
||
loadProduct() {
|
||
const inp = document.createElement("input");
|
||
inp.type = "file";
|
||
inp.accept = ".json";
|
||
inp.addEventListener("change", (e) => {
|
||
const f = e.target.files[0];
|
||
if (!f) return;
|
||
const r = new FileReader();
|
||
r.onload = (ev) => {
|
||
try {
|
||
const ld = JSON.parse(ev.target.result);
|
||
if (!validateAppDataStructure(ld)) {
|
||
showToast("❌ ساختار نامعتبر", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
projectData.appData = ld;
|
||
AutosaveManager.clear();
|
||
AutosaveManager.save();
|
||
updateProjectUI();
|
||
renderToolGroups();
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
ToolbarManager.populateOwnerFilter();
|
||
refreshUI();
|
||
showToast("📂 بارگذاری شد", "success");
|
||
} catch (err) {
|
||
showToast("❌ خطا: " + err.message, "error");
|
||
}
|
||
};
|
||
r.readAsText(f);
|
||
});
|
||
inp.click();
|
||
},
|
||
clearAll() {
|
||
if (!confirm("⚠️ پاکسازی کامل؟ این عمل غیرقابل بازگشت است!")) return;
|
||
if (!confirm("⚠️ تأیید نهایی؟")) return;
|
||
HistoryManager.pushState();
|
||
projectData.appData = {
|
||
environments: [],
|
||
products: [],
|
||
boards: [],
|
||
boardTags: [],
|
||
};
|
||
AutosaveManager.clear();
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
ToolbarManager.populateOwnerFilter();
|
||
refreshUI();
|
||
showToast("🗑️ پاکسازی شد", "success");
|
||
},
|
||
showGalaxyChart() {
|
||
GalaxyChartManager.open();
|
||
},
|
||
showExecutiveDashboard() {
|
||
DashboardManager.open();
|
||
},
|
||
showDependencyAnalysis() {
|
||
DependencyAnalysisManager.open();
|
||
},
|
||
showDeploymentAnalysis() {
|
||
DeploymentAnalysisManager.open();
|
||
},
|
||
showVersionAnalysis() {
|
||
VersionAnalysisManager.open();
|
||
},
|
||
showRiskAnalysis() {
|
||
RiskAnalysisManager.open();
|
||
},
|
||
showReleaseBoards() {
|
||
ReleaseBoardsManager.open();
|
||
},
|
||
showTagManager() {
|
||
ReleaseBoardsManager.openTagManager();
|
||
},
|
||
defaultAction(tool) {
|
||
showToast(`ابزار: ${tool.title}`, "info");
|
||
},
|
||
};
|
||
/* ==================== WEBHOOK MANAGER ==================== */
|
||
const WebhookManager = {
|
||
getConfig() {
|
||
return projectData.webhook;
|
||
},
|
||
setConfig(c) {
|
||
projectData.webhook = { ...projectData.webhook, ...c };
|
||
this.updateStatusUI();
|
||
},
|
||
isValid() {
|
||
const c = this.getConfig();
|
||
return (
|
||
c.enabled && c.url && c.clientId && /^https?:\/\//i.test(c.url)
|
||
);
|
||
},
|
||
updateStatusUI() {
|
||
const c = this.getConfig();
|
||
const d = document.getElementById("webhookStatusDot"),
|
||
t = document.getElementById("webhookStatusText");
|
||
if (!d || !t) return;
|
||
d.className = "webhook-status-dot";
|
||
if (!c.enabled) t.textContent = "وضعیت: غیرفعال";
|
||
else if (!c.url || !c.clientId) {
|
||
d.classList.add("pending");
|
||
t.textContent = "وضعیت: ناقص";
|
||
} else {
|
||
d.classList.add("success");
|
||
t.textContent = "وضعیت: آماده";
|
||
}
|
||
},
|
||
async sendEvent(en, payload = {}) {
|
||
if (!this.isValid()) return;
|
||
const c = this.getConfig();
|
||
try {
|
||
await fetch(c.url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Client-Id": c.clientId,
|
||
"X-Event-Name": en,
|
||
},
|
||
body: JSON.stringify({
|
||
event: en,
|
||
timestamp: new Date().toISOString(),
|
||
projectId: projectData.projectId,
|
||
data: payload,
|
||
}),
|
||
});
|
||
} catch (e) { }
|
||
},
|
||
};
|
||
/* ==================== ENVIRONMENT MANAGER ==================== */
|
||
const EnvironmentManager = {
|
||
currentEditEnvId: null,
|
||
openAddModal() {
|
||
document.getElementById("newEnvName").value = "";
|
||
document.getElementById("newEnvDesc").value = "";
|
||
document.getElementById("newEnvBgColor").value = "#38a169";
|
||
document.getElementById("newEnvTextColor").value = "#ffffff";
|
||
ModalManager.open("envManagerModal");
|
||
},
|
||
saveNewEnv() {
|
||
const n = document.getElementById("newEnvName").value.trim();
|
||
if (!n) {
|
||
showToast("نام الزامی است", "error");
|
||
return;
|
||
}
|
||
if (
|
||
projectData.appData.environments.some(
|
||
(e) => e.name.toLowerCase() === n.toLowerCase(),
|
||
)
|
||
) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
projectData.appData.environments.push({
|
||
id: `env-${Date.now()}`,
|
||
name: n,
|
||
description:
|
||
document.getElementById("newEnvDesc").value.trim() || "",
|
||
backgroundColor: document.getElementById("newEnvBgColor").value,
|
||
textColor: document.getElementById("newEnvTextColor").value,
|
||
});
|
||
ModalManager.close("envManagerModal");
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast(`محیط "${n}" اضافه شد`, "success");
|
||
},
|
||
openEditModal(envId) {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === envId,
|
||
);
|
||
if (!env) return;
|
||
this.currentEditEnvId = envId;
|
||
document.getElementById("editEnvId").value = env.id;
|
||
document.getElementById("editEnvName").value = env.name;
|
||
document.getElementById("editEnvDesc").value = env.description || "";
|
||
document.getElementById("editEnvBgColor").value =
|
||
env.backgroundColor || "#38a169";
|
||
document.getElementById("editEnvTextColor").value =
|
||
env.textColor || "#ffffff";
|
||
this.updateEditPreview();
|
||
ModalManager.open("envEditModal");
|
||
},
|
||
updateEditPreview() {
|
||
const p = document.getElementById("editEnvPreview");
|
||
p.style.backgroundColor =
|
||
document.getElementById("editEnvBgColor").value;
|
||
p.style.color = document.getElementById("editEnvTextColor").value;
|
||
},
|
||
saveEdit() {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === this.currentEditEnvId,
|
||
);
|
||
if (!env) return;
|
||
const nn = document.getElementById("editEnvName").value.trim();
|
||
if (!nn) {
|
||
showToast("نام الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
env.name = nn;
|
||
env.description = document.getElementById("editEnvDesc").value.trim();
|
||
env.backgroundColor = document.getElementById("editEnvBgColor").value;
|
||
env.textColor = document.getElementById("editEnvTextColor").value;
|
||
ModalManager.close("envEditModal");
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast(`محیط "${env.name}" ویرایش شد`, "success");
|
||
},
|
||
remove(id) {
|
||
const env = projectData.appData.environments.find((e) => e.id === id);
|
||
if (!env) return;
|
||
if (!confirm(`حذف "${env.name}"؟`)) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
projectData.appData.environments =
|
||
projectData.appData.environments.filter((e) => e.id !== id);
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast(`"${env.name}" حذف شد`, "warning");
|
||
},
|
||
};
|
||
/* ==================== PRODUCT MANAGER ==================== */
|
||
const ProductManager = {
|
||
currentDetailProductId: null,
|
||
currentEditProjectId: null,
|
||
currentDependencyProjectId: null,
|
||
currentDependencyProjId: null,
|
||
currentDeploymentProjId: null,
|
||
currentDeploymentMode: null,
|
||
currentDeploymentEnvId: null,
|
||
currentEditDeploymentId: null,
|
||
currentExploitProjectId: null,
|
||
currentExploitProjId: null,
|
||
getTypeInfo(type) {
|
||
return (
|
||
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
|
||
value: type,
|
||
label: type,
|
||
icon: "❓",
|
||
}
|
||
);
|
||
},
|
||
getEnvironmentName(envId) {
|
||
const e = projectData.appData.environments.find(
|
||
(x) => x.id === envId,
|
||
);
|
||
return e ? e.name : envId;
|
||
},
|
||
getEnvironmentColors(envId) {
|
||
const e = projectData.appData.environments.find(
|
||
(x) => x.id === envId,
|
||
);
|
||
return {
|
||
backgroundColor: e?.backgroundColor || "#38a169",
|
||
textColor: e?.textColor || "#ffffff",
|
||
};
|
||
},
|
||
findProjectById(pid) {
|
||
for (const prod of projectData.appData.products) {
|
||
const pr = prod.projects.find((p) => p.id === pid);
|
||
if (pr) return pr;
|
||
}
|
||
return null;
|
||
},
|
||
getActiveDeployments(proj) {
|
||
return (proj.deployments || [])
|
||
.filter((d) => d.status === "active")
|
||
.sort((a, b) => {
|
||
const dateA = a.deploymentDate ? new Date(a.deploymentDate).getTime() : 0;
|
||
const dateB = b.deploymentDate ? new Date(b.deploymentDate).getTime() : 0;
|
||
return dateB - dateA;
|
||
});
|
||
},
|
||
getEnvironmentsWithoutDeployment(proj) {
|
||
const allEnvIds = projectData.appData.environments.map((e) => e.id);
|
||
const deployedEnvIds = this.getActiveDeployments(proj).map(
|
||
(d) => d.environmentId,
|
||
);
|
||
return allEnvIds.filter((id) => !deployedEnvIds.includes(id));
|
||
},
|
||
showDetail(productId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
this.currentDetailProductId = productId;
|
||
document.getElementById("projectDetailTitle").innerHTML =
|
||
`<span>📦</span><span>${prod.title}</span>`;
|
||
const projects = prod.projects || [];
|
||
const totalDeploys = projects.reduce(
|
||
(s, pr) => s + this.getActiveDeployments(pr).length,
|
||
0,
|
||
);
|
||
|
||
let html = `<div class="detail-section"><div class="detail-section-header"><span>ℹ️ اطلاعات محصول</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openProductEditModal()">✏️ ویرایش</button></div></div><div class="detail-section-body"><div class="detail-info-grid"><div class="detail-info-item"><span class="detail-info-label">عنوان</span><span class="detail-info-value">${prod.title}</span></div><div class="detail-info-item"><span class="detail-info-label">توضیحات</span><span class="detail-info-value">${prod.description || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تاریخ ایجاد</span><span class="detail-info-value">${prod.metadata?.createdAt ? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR") : "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">مالک</span><span class="detail-info-value">${prod.metadata?.owner || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تعداد پروژه</span><span class="detail-info-value">${projects.length}</span></div><div class="detail-info-item"><span class="detail-info-label">استقرارهای فعال</span><span class="detail-info-value">${totalDeploys}</span></div></div></div></div><div class="detail-section"><div class="detail-section-header"><span>🌿 پروژهها (${projects.length})</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openAddProjectModal()">➕ افزودن</button></div></div><div class="detail-section-body">`;
|
||
|
||
if (projects.length === 0) {
|
||
html += '<div class="empty-subprojects">🌿 هیچ پروژهای نیست</div>';
|
||
} else {
|
||
html += '<div class="subproject-list">';
|
||
projects.forEach((pr) => {
|
||
const ti = this.getTypeInfo(pr.type);
|
||
const deps = pr.dependencies || [];
|
||
const exploits = pr.exploits || [];
|
||
const deploys = this.getActiveDeployments(pr);
|
||
const archivedDeploys = (pr.deployments || [])
|
||
.filter((d) => d.status === "archived")
|
||
.sort((a, b) => {
|
||
const dateA = a.archivedAt ? new Date(a.archivedAt).getTime() : 0;
|
||
const dateB = b.archivedAt ? new Date(b.archivedAt).getTime() : 0;
|
||
return dateB - dateA;
|
||
});
|
||
const hasEnvs = projectData.appData.environments.length > 0;
|
||
|
||
html += `<div class="subproject-card"><div class="subproject-header"><div class="subproject-title"><span>${ti.icon}</span><span>${pr.title}</span><span class="type-badge type-${pr.type}">${ti.label}</span></div><div class="subproject-actions"><button class="btn btn-ghost btn-sm" onclick="ProductManager.openDependencyManager('${prod.id}','${pr.id}')">🔗 وابستگیها</button><button class="btn btn-ghost btn-sm" onclick="ProductManager.openProjectEditModal('${prod.id}','${pr.id}')">✏️</button><button class="btn btn-danger btn-sm" onclick="ProductManager.removeProject('${prod.id}','${pr.id}')">🗑️</button></div></div><div class="subproject-meta">${pr.description || ""}</div><div class="detail-info-grid" style="margin-bottom:8px">`;
|
||
|
||
if (pr.metadata?.owner) {
|
||
html += `<div class="detail-info-item"><span class="detail-info-label">👤 مالک</span><span class="detail-info-value">${pr.metadata.owner}</span></div>`;
|
||
}
|
||
|
||
if (pr.metadata?.framework) {
|
||
html += `<div class="detail-info-item"><span class="detail-info-label">فریمورک</span><span class="detail-info-value">${pr.metadata.framework}</span></div>`;
|
||
}
|
||
|
||
if (pr.metadata?.language) {
|
||
html += `<div class="detail-info-item"><span class="detail-info-label">زبان</span><span class="detail-info-value">${pr.metadata.language}</span></div>`;
|
||
}
|
||
|
||
if (deps.length > 0) {
|
||
html += `<div class="detail-info-item"><span class="detail-info-label">وابستگیها (${deps.length})</span><span class="detail-info-value">${deps
|
||
.map((depId) => {
|
||
const dp = this.findProjectById(depId);
|
||
return dp ? dp.title : depId;
|
||
})
|
||
.join("، ")}</span></div>`;
|
||
}
|
||
|
||
if (exploits.length > 0) {
|
||
html += `<div class="detail-info-item"><span class="detail-info-label">سرویسهای مورد استفاده (${exploits.length})</span><span class="detail-info-value">${exploits
|
||
.map((expId) => {
|
||
const ep = this.findProjectById(expId);
|
||
return ep ? ep.title : expId;
|
||
})
|
||
.join("، ")}</span></div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
html += `<div class="deployment-section"><div class="deployment-header"><span class="deployment-header-title">🚀 استقرارهای فعال (${deploys.length})</span>${hasEnvs ? `<button class="btn btn-accent btn-xs" onclick="ProductManager.openAddDeploymentModal('${prod.id}','${pr.id}')">➕ افزودن</button>` : '<span style="font-size:10px;color:var(--text-muted)">⚠️ محیطی تعریف نشده</span>'}</div>`;
|
||
|
||
if (deploys.length === 0) {
|
||
html += '<div class="empty-deployments">هنوز استقرار ندارد</div>';
|
||
} else {
|
||
html += '<div class="deployment-list">';
|
||
deploys.forEach((d) => {
|
||
const en = this.getEnvironmentName(d.environmentId);
|
||
const cl = this.getEnvironmentColors(d.environmentId);
|
||
const fd = d.deploymentDate
|
||
? new Date(d.deploymentDate).toLocaleString("fa-IR")
|
||
: "—";
|
||
|
||
html += `<div class="deployment-item"><div class="deployment-item-info"><span class="deployment-env-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${en}</span><span class="deployment-version">v${d.version}</span><span class="deployment-date">📅 ${fd}</span></div><div class="deployment-actions">`;
|
||
|
||
html += `<button class="btn btn-ghost btn-xs" onclick="ProductManager.openEditDeploymentModal('${prod.id}','${pr.id}','${d.id}')" title="ویرایش">✏️</button>`;
|
||
|
||
html += `<button class="btn btn-xs" style="background:#dd6b20;color:#fff" onclick="ProductManager.archiveDeployment('${prod.id}','${pr.id}','${d.id}')" title="انتقال به تاریخچه">📦 آرشیو</button>`;
|
||
|
||
html += `<button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')" title="حذف کامل">🗑️</button>`;
|
||
|
||
html += `</div>`;
|
||
|
||
if (d.releaseNotes) {
|
||
html += `<div class="deployment-notes">📝 ${d.releaseNotes}</div>`;
|
||
}
|
||
|
||
if (d.requiredDeployments && d.requiredDeployments.length > 0) {
|
||
html += `<div class="deployment-req">🔗 وابستگیها: ${d.requiredDeployments
|
||
.map((rd) => {
|
||
const rp = this.findProjectById(rd.projectId);
|
||
let depVer = "";
|
||
if (rp) {
|
||
const rdDep = (rp.deployments || []).find(
|
||
(x) => x.id === rd.deploymentId,
|
||
);
|
||
if (rdDep) depVer = ` (v${rdDep.version})`;
|
||
}
|
||
return `${rp ? rp.title : rd.projectId}${depVer}`;
|
||
})
|
||
.join("، ")}</div>`;
|
||
}
|
||
|
||
if (d.requiredExploits && d.requiredExploits.length > 0) {
|
||
html += `<div class="deployment-req" style="color:var(--info)">🔌 سرویسها: ${d.requiredExploits
|
||
.map((re) => {
|
||
const rp = this.findProjectById(re.projectId);
|
||
let depVer = "";
|
||
if (rp) {
|
||
const reDep = (rp.deployments || []).find(
|
||
(x) => x.id === re.deploymentId,
|
||
);
|
||
if (reDep) depVer = ` (v${reDep.version})`;
|
||
}
|
||
return `${rp ? rp.title : re.projectId}${depVer}`;
|
||
})
|
||
.join("، ")}</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
});
|
||
html += "</div>";
|
||
}
|
||
|
||
html += `<div class="deployment-section" style="margin-top:12px;border-top:2px dashed var(--border)">`;
|
||
html += `<div class="deployment-header">`;
|
||
html += `<span class="deployment-header-title">📜 تاریخچه استقرارها (${archivedDeploys.length})</span>`;
|
||
if (archivedDeploys.length > 0) {
|
||
html += `<button class="btn btn-ghost btn-xs" onclick="ProductManager.toggleArchiveVisibility('${pr.id}')" id="archiveToggle-${pr.id}">👁️ نمایش/مخفی</button>`;
|
||
}
|
||
html += `</div>`;
|
||
|
||
if (archivedDeploys.length === 0) {
|
||
html += `<div class="empty-deployments" style="opacity:0.6">هنوز استقراری آرشیو نشده است</div>`;
|
||
} else {
|
||
html += `<div class="deployment-list" id="archiveList-${pr.id}" style="display:none">`;
|
||
archivedDeploys.forEach((d) => {
|
||
const en = this.getEnvironmentName(d.environmentId);
|
||
const cl = this.getEnvironmentColors(d.environmentId);
|
||
const fd = d.deploymentDate
|
||
? new Date(d.deploymentDate).toLocaleString("fa-IR")
|
||
: "—";
|
||
const archivedDate = d.archivedAt
|
||
? new Date(d.archivedAt).toLocaleString("fa-IR")
|
||
: "—";
|
||
|
||
html += `<div class="deployment-item" style="opacity:0.75;background:var(--border-light)">`;
|
||
html += `<div class="deployment-item-info">`;
|
||
html += `<span class="deployment-env-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor};opacity:0.7">${en}</span>`;
|
||
html += `<span class="deployment-version" style="opacity:0.7">v${d.version}</span>`;
|
||
html += `<span class="deployment-date">📅 ${fd}</span>`;
|
||
html += `<span class="deployment-date" style="color:var(--warning)">📦 آرشیو: ${archivedDate}</span>`;
|
||
html += `</div>`;
|
||
html += `<div class="deployment-actions">`;
|
||
html += `<button class="btn btn-xs" style="background:#3182ce;color:#fff" onclick="ProductManager.restoreDeployment('${prod.id}','${pr.id}','${d.id}')" title="بازگرداندن به لیست فعال">↩️ بازگردانی</button>`;
|
||
html += `<button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')" title="حذف کامل">🗑️</button>`;
|
||
html += `</div>`;
|
||
|
||
if (d.releaseNotes) {
|
||
html += `<div class="deployment-notes" style="opacity:0.7">📝 ${d.releaseNotes}</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
|
||
html += `</div></div>`;
|
||
});
|
||
html += "</div>";
|
||
}
|
||
|
||
html += `</div></div>`;
|
||
document.getElementById("projectDetailBody").innerHTML = html;
|
||
ModalManager.open("projectDetailModal");
|
||
},
|
||
openAddProductModal() {
|
||
document.getElementById("newProductTitle").value = "";
|
||
document.getElementById("newProductDescription").value = "";
|
||
document.getElementById("newProductOwner").value = "";
|
||
document.getElementById("newProductCreatedAt").value =
|
||
new Date().toLocaleDateString("fa-IR");
|
||
ModalManager.open("addProductModal");
|
||
},
|
||
createNewProduct() {
|
||
const title = document.getElementById("newProductTitle").value.trim();
|
||
if (!title || title.length < 3) {
|
||
showToast("عنوان الزامی است (حداقل ۳ کاراکتر)", "error");
|
||
return;
|
||
}
|
||
if (
|
||
projectData.appData.products.some(
|
||
(p) => p.title.toLowerCase() === title.toLowerCase(),
|
||
)
|
||
) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
projectData.appData.products.push({
|
||
id: `prod-${Date.now()}`,
|
||
title,
|
||
description:
|
||
document.getElementById("newProductDescription").value.trim() ||
|
||
"",
|
||
metadata: {
|
||
createdAt: new Date().toISOString(),
|
||
owner:
|
||
document.getElementById("newProductOwner").value.trim() || "",
|
||
customFields: {},
|
||
},
|
||
projects: [],
|
||
});
|
||
ModalManager.close("addProductModal");
|
||
refreshUI();
|
||
ToolbarManager.populateOwnerFilter();
|
||
showToast(`"${title}" ایجاد شد`, "success");
|
||
},
|
||
openDependencyManager(productId, projId) {
|
||
this.currentDependencyProjectId = productId;
|
||
this.currentDependencyProjId = projId;
|
||
this.renderCurrentDependencies();
|
||
this.renderAllProjectsList();
|
||
this.setupDependencySearch();
|
||
ModalManager.open("dependencyManagerModal");
|
||
},
|
||
renderCurrentDependencies() {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!pr) return;
|
||
const c = document.getElementById("currentDependenciesList");
|
||
const deps = pr.dependencies || [];
|
||
if (deps.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="empty-dependencies">هیچ وابستگیای نیست</div>';
|
||
return;
|
||
}
|
||
c.innerHTML = deps
|
||
.map((depId, index) => {
|
||
const depPr = this.findProjectById(depId);
|
||
const projectName = depPr ? depPr.title : depId;
|
||
const typeInfo = depPr ? this.getTypeInfo(depPr.type) : null;
|
||
return `<div class="dependency-item"><div class="dependency-info"><div><div class="dependency-project-name">${typeInfo ? typeInfo.icon + " " : ""}${projectName}</div></div></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDependency(${index})">🗑️</button></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
removeDependency(index) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!pr) return;
|
||
if (!confirm("حذف این وابستگی؟")) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
pr.dependencies.splice(index, 1);
|
||
ModalManager.close("dependencyManagerModal");
|
||
refreshUI();
|
||
if (this.currentDetailProductId)
|
||
this.showDetail(this.currentDetailProductId);
|
||
showToast("وابستگی حذف شد", "success");
|
||
},
|
||
renderAllProjectsList() {
|
||
const rc = document.getElementById("dependencySearchResults");
|
||
const cp = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!cp) return;
|
||
const cpr = cp.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!cpr) return;
|
||
const allProjects = [];
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
if (pr.id !== cpr.id)
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
});
|
||
});
|
||
if (allProjects.length === 0) {
|
||
rc.innerHTML =
|
||
'<div class="empty-dependencies">پروژه دیگری نیست</div>';
|
||
return;
|
||
}
|
||
rc.innerHTML = allProjects
|
||
.map((pr) => {
|
||
const isDep = (cpr.dependencies || []).includes(pr.id);
|
||
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted);font-weight:normal">(${pr.productName})</small>${isDep ? '<span style="margin-right:auto;font-size:10px;color:var(--success)">✓ وابسته</span>' : ""}</button></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
setupDependencySearch() {
|
||
const si = document.getElementById("dependencySearch");
|
||
si.value = "";
|
||
si.oninput = (e) => {
|
||
const q = e.target.value.trim().toLowerCase();
|
||
if (q.length === 0) {
|
||
this.renderAllProjectsList();
|
||
return;
|
||
}
|
||
const cp = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!cp) return;
|
||
const cpr = cp.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!cpr) return;
|
||
const allProjects = [];
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
if (pr.id !== cpr.id)
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
});
|
||
});
|
||
const fl = allProjects.filter(
|
||
(pr) =>
|
||
pr.title.toLowerCase().includes(q) ||
|
||
pr.productName.toLowerCase().includes(q) ||
|
||
(pr.description || "").toLowerCase().includes(q),
|
||
);
|
||
const rc = document.getElementById("dependencySearchResults");
|
||
if (fl.length === 0) {
|
||
rc.innerHTML =
|
||
'<div class="empty-dependencies">نتیجهای نیست</div>';
|
||
return;
|
||
}
|
||
rc.innerHTML = fl
|
||
.map((pr) => {
|
||
const isDep = (cpr.dependencies || []).includes(pr.id);
|
||
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted)">(${pr.productName})</small></button></div>`;
|
||
})
|
||
.join("");
|
||
};
|
||
},
|
||
addDependency(projectId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!pr) return;
|
||
if ((pr.dependencies || []).includes(projectId)) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
if (!pr.dependencies) pr.dependencies = [];
|
||
pr.dependencies.push(projectId);
|
||
ModalManager.close("dependencyManagerModal");
|
||
refreshUI();
|
||
if (this.currentDetailProductId)
|
||
this.showDetail(this.currentDetailProductId);
|
||
showToast("وابستگی اضافه شد", "success");
|
||
},
|
||
openProductEditModal() {
|
||
const pid = this.currentDetailProductId;
|
||
if (!pid) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
document.getElementById("editProductId").value = prod.id;
|
||
document.getElementById("editProductTitle").value = prod.title;
|
||
document.getElementById("editProductDescription").value =
|
||
prod.description || "";
|
||
document.getElementById("editProductOwner").value =
|
||
prod.metadata?.owner || "";
|
||
document.getElementById("editProductCreatedAt").value = prod.metadata
|
||
?.createdAt
|
||
? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR")
|
||
: "—";
|
||
ModalManager.open("productEditModal");
|
||
},
|
||
saveProductEdit() {
|
||
const pid = this.currentDetailProductId;
|
||
if (!pid) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const nt = document.getElementById("editProductTitle").value.trim();
|
||
if (!nt) {
|
||
showToast("عنوان الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
prod.title = nt;
|
||
prod.description = document
|
||
.getElementById("editProductDescription")
|
||
.value.trim();
|
||
if (!prod.metadata) prod.metadata = {};
|
||
prod.metadata.owner = document
|
||
.getElementById("editProductOwner")
|
||
.value.trim();
|
||
prod.metadata.updatedAt = new Date().toISOString();
|
||
ModalManager.close("productEditModal");
|
||
refreshUI();
|
||
ToolbarManager.populateOwnerFilter();
|
||
this.showDetail(pid);
|
||
showToast(`"${prod.title}" ویرایش شد`, "success");
|
||
},
|
||
openAddProjectModal() {
|
||
if (!this.currentDetailProductId) {
|
||
showToast("ابتدا محصول انتخاب کنید", "warning");
|
||
return;
|
||
}
|
||
document.getElementById("newProjectTitle").value = "";
|
||
document.getElementById("newProjectType").value = "";
|
||
document.getElementById("newProjectFramework").value = "";
|
||
document.getElementById("newProjectOwner").value = ""; // 🆕
|
||
document.getElementById("newProjectLanguage").value = "";
|
||
document.getElementById("newProjectRepo").value = "";
|
||
document.getElementById("newProjectDescription").value = "";
|
||
populateProjectTypeDropdowns();
|
||
ModalManager.open("addProjectModal");
|
||
},
|
||
saveNewProject() {
|
||
const pid = this.currentDetailProductId;
|
||
if (!pid) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const title = document.getElementById("newProjectTitle").value.trim();
|
||
const type = document.getElementById("newProjectType").value;
|
||
if (!title || !type) {
|
||
showToast("عنوان و نوع الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
const np = {
|
||
id: `proj-${Date.now()}`,
|
||
title,
|
||
description: document.getElementById("newProjectDescription").value.trim() || "",
|
||
type,
|
||
repositoryUrl: document.getElementById("newProjectRepo").value.trim(),
|
||
metadata: {
|
||
createdAt: new Date().toISOString(),
|
||
owner: document.getElementById("newProjectOwner").value.trim() || "",
|
||
customFields: {}
|
||
},
|
||
dependencies: [],
|
||
exploits: [],
|
||
deployments: [],
|
||
};
|
||
const fw = document.getElementById("newProjectFramework").value.trim();
|
||
if (fw) np.metadata.framework = fw;
|
||
const lg = document.getElementById("newProjectLanguage").value.trim();
|
||
if (lg) np.metadata.language = lg;
|
||
prod.projects.push(np);
|
||
ModalManager.close("addProjectModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
showToast(`"${title}" ایجاد شد`, "success");
|
||
},
|
||
openProjectEditModal(productId, projId) {
|
||
const prod = projectData.appData.products.find((p) => p.id === productId);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
this.currentEditProjectId = projId;
|
||
populateProjectTypeDropdowns();
|
||
document.getElementById("editProjId").value = pr.id;
|
||
document.getElementById("editProjTitle").value = pr.title;
|
||
document.getElementById("editProjType").value = pr.type;
|
||
document.getElementById("editProjFramework").value = pr.metadata?.framework || "";
|
||
document.getElementById("editProjOwner").value = pr.metadata?.owner || ""; // 🆕
|
||
document.getElementById("editProjLanguage").value = pr.metadata?.language || "";
|
||
document.getElementById("editProjRepo").value = pr.repositoryUrl || "";
|
||
document.getElementById("editProjDescription").value = pr.description || "";
|
||
ModalManager.open("editProjectModal");
|
||
},
|
||
saveProjectEdit() {
|
||
const pid = this.currentDetailProductId;
|
||
const projId = this.currentEditProjectId;
|
||
if (!pid || !projId) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const nt = document.getElementById("editProjTitle").value.trim();
|
||
const nty = document.getElementById("editProjType").value;
|
||
if (!nt || !nty) {
|
||
showToast("عنوان و نوع الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
pr.title = nt;
|
||
pr.type = nty;
|
||
pr.description = document.getElementById("editProjDescription").value.trim() || "";
|
||
pr.repositoryUrl = document.getElementById("editProjRepo").value.trim();
|
||
if (!pr.metadata) pr.metadata = {};
|
||
const fw = document.getElementById("editProjFramework").value.trim();
|
||
if (fw) pr.metadata.framework = fw;
|
||
else delete pr.metadata.framework;
|
||
const owner = document.getElementById("editProjOwner").value.trim();
|
||
if (owner) pr.metadata.owner = owner;
|
||
else delete pr.metadata.owner;
|
||
const lg = document.getElementById("editProjLanguage").value.trim();
|
||
if (lg) pr.metadata.language = lg;
|
||
else delete pr.metadata.language;
|
||
pr.metadata.updatedAt = new Date().toISOString();
|
||
|
||
if (!pr.exploits) pr.exploits = [];
|
||
|
||
ModalManager.close("editProjectModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
showToast(`"${pr.title}" ویرایش شد`, "success");
|
||
},
|
||
openAddDeploymentModal(productId, projId) {
|
||
if (!productId || !projId) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === productId);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
|
||
const allEnvs = projectData.appData.environments;
|
||
if (allEnvs.length === 0) {
|
||
showToast("ابتدا یک محیط عملیاتی ایجاد کنید", "warning");
|
||
return;
|
||
}
|
||
|
||
this.currentDeploymentProjId = projId;
|
||
this.currentDeploymentMode = "add";
|
||
this.currentDeploymentEnvId = null;
|
||
this.currentEditDeploymentId = null;
|
||
|
||
document.getElementById("deploymentModalTitle").innerHTML =
|
||
"<span>🚀</span><span>افزودن نسخه استقرار</span>";
|
||
|
||
const envGroup = document.getElementById("deploymentEnvGroup");
|
||
const envs = allEnvs;
|
||
|
||
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="required">*</span></label><select class="form-select" id="depEnvironment" onchange="ProductManager.onEnvChange()">${envs
|
||
.map((e) => {
|
||
const cl = this.getEnvironmentColors(e.id);
|
||
return `<option value="${e.id}" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${e.name} - ${e.description}</option>`;
|
||
})
|
||
.join("")}</select>`;
|
||
|
||
document.getElementById("depVersion").value = "";
|
||
document.getElementById("depDate").value = "";
|
||
document.getElementById("depReleaseNotes").value = "";
|
||
|
||
this.renderRequiredDeploymentsSection();
|
||
ModalManager.open("deploymentModal");
|
||
},
|
||
openEditDeploymentModal(productId, projId, depId) {
|
||
if (!productId || !projId || !depId) return;
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const dep = (pr.deployments || []).find((d) => d.id === depId);
|
||
if (!dep) return;
|
||
this.currentDeploymentProjId = projId;
|
||
this.currentDeploymentMode = "edit";
|
||
this.currentDeploymentEnvId = dep.environmentId;
|
||
this.currentEditDeploymentId = depId;
|
||
document.getElementById("deploymentModalTitle").innerHTML =
|
||
"<span>✏️</span><span>ویرایش نسخه استقرار</span>";
|
||
const envGroup = document.getElementById("deploymentEnvGroup");
|
||
const cl = this.getEnvironmentColors(dep.environmentId);
|
||
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="readonly-badge">فقط خواندنی</span></label><div class="env-read-only-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${this.getEnvironmentName(dep.environmentId)}</div>`;
|
||
document.getElementById("depVersion").value = dep.version;
|
||
let dv = "";
|
||
if (dep.deploymentDate) {
|
||
const d = new Date(dep.deploymentDate);
|
||
const pad = (n) => n.toString().padStart(2, "0");
|
||
dv = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
document.getElementById("depDate").value = dv;
|
||
document.getElementById("depReleaseNotes").value =
|
||
dep.releaseNotes || "";
|
||
this.renderRequiredDeploymentsSection(dep);
|
||
ModalManager.open("deploymentModal");
|
||
},
|
||
onEnvChange() {
|
||
this.renderRequiredDeploymentsSection();
|
||
},
|
||
renderRequiredDeploymentsSection(existingDep) {
|
||
const section = document.getElementById("requiredDeploymentsSection");
|
||
const envSelect = document.getElementById("depEnvironment");
|
||
const envId =
|
||
this.currentDeploymentMode === "edit"
|
||
? this.currentDeploymentEnvId
|
||
: envSelect
|
||
? envSelect.value
|
||
: null;
|
||
if (!envId) {
|
||
section.innerHTML = "";
|
||
return;
|
||
}
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDetailProductId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDeploymentProjId,
|
||
);
|
||
if (!pr) return;
|
||
|
||
const deps = pr.dependencies || [];
|
||
const exploits = pr.exploits || [];
|
||
if (deps.length === 0 && exploits.length === 0) {
|
||
section.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
let h = '<div class="form-group">';
|
||
|
||
if (deps.length > 0) {
|
||
h += `<label class="form-label">🔗 استقرارهای مورد نیاز (وابستگیها) <span class="required">*</span></label><div class="form-hint">برای هر وابستگی، یک استقرار از آن پروژه در محیط "${this.getEnvironmentName(envId)}" انتخاب کنید</div>`;
|
||
deps.forEach((depId) => {
|
||
const depPr = this.findProjectById(depId);
|
||
if (!depPr) return;
|
||
const depDeployments = (depPr.deployments || []).filter(
|
||
(d) => d.status === "active" && d.environmentId === envId,
|
||
);
|
||
const existingReq = existingDep
|
||
? (existingDep.requiredDeployments || []).find(
|
||
(r) => r.projectId === depId,
|
||
)
|
||
: null;
|
||
h += `<div class="req-deployment-box"><div class="req-deployment-title">📦 ${depPr.title}</div>`;
|
||
if (depDeployments.length === 0) {
|
||
h += `<div style="color:var(--danger);font-size:11px">⚠️ هیچ استقرار فعالی در ${this.getEnvironmentName(envId)} ندارد.</div>`;
|
||
} else {
|
||
h += `<select class="req-deployment-select" data-dep-project="${depId}" data-dep-type="dependency">${depDeployments.map((d) => `<option value="${d.id}" ${existingReq && existingReq.deploymentId === d.id ? "selected" : ""}>v${d.version} - ${d.deploymentDate ? new Date(d.deploymentDate).toLocaleDateString("fa-IR") : ""}</option>`).join("")}</select>`;
|
||
}
|
||
h += "</div>";
|
||
});
|
||
}
|
||
|
||
if (exploits.length > 0) {
|
||
h += `<label class="form-label" style="margin-top:12px">🔌 سرویسهای مورد استفاده (اختیاری)</label><div class="form-hint">برای هر سرویس، در صورت تمایل یک استقرار از آن پروژه در محیط "${this.getEnvironmentName(envId)}" انتخاب کنید</div>`;
|
||
exploits.forEach((expId) => {
|
||
const expPr = this.findProjectById(expId);
|
||
if (!expPr) return;
|
||
const expDeployments = (expPr.deployments || []).filter(
|
||
(d) => d.status === "active" && d.environmentId === envId,
|
||
);
|
||
const existingReq = existingDep
|
||
? (existingDep.requiredExploits || []).find(
|
||
(r) => r.projectId === expId,
|
||
)
|
||
: null;
|
||
h += `<div class="req-deployment-box" style="border-color:var(--info)"><div class="req-deployment-title" style="color:var(--info)">🔌 ${expPr.title}</div>`;
|
||
if (expDeployments.length === 0) {
|
||
h += `<div style="color:var(--text-muted);font-size:11px">ℹ️ هیچ استقرار فعالی در ${this.getEnvironmentName(envId)} ندارد (اختیاری)</div>`;
|
||
} else {
|
||
h += `<select class="req-deployment-select" data-exploit-project="${expId}" data-dep-type="exploit"><option value="">انتخاب نکنید</option>${expDeployments.map((d) => `<option value="${d.id}" ${existingReq && existingReq.deploymentId === d.id ? "selected" : ""}>v${d.version} - ${d.deploymentDate ? new Date(d.deploymentDate).toLocaleDateString("fa-IR") : ""}</option>`).join("")}</select>`;
|
||
}
|
||
h += "</div>";
|
||
});
|
||
}
|
||
|
||
h += "</div>";
|
||
section.innerHTML = h;
|
||
},
|
||
saveDeployment() {
|
||
const pid = this.currentDetailProductId;
|
||
const projId = this.currentDeploymentProjId;
|
||
if (!pid || !projId) {
|
||
showToast("خطا: محصول یا پروژه مشخص نشده", "error");
|
||
return;
|
||
}
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const version = document.getElementById("depVersion").value.trim();
|
||
const date = document.getElementById("depDate").value;
|
||
const releaseNotes = document.getElementById("depReleaseNotes").value.trim();
|
||
if (!version) {
|
||
showToast("نسخه الزامی است", "error");
|
||
return;
|
||
}
|
||
if (!date) {
|
||
showToast("تاریخ استقرار الزامی است", "error");
|
||
return;
|
||
}
|
||
if (!releaseNotes) {
|
||
showToast("ریلیز نوت الزامی است", "error");
|
||
return;
|
||
}
|
||
const deploymentDate = new Date(date).toISOString();
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
if (!pr.deployments) pr.deployments = [];
|
||
|
||
const requiredDeployments = [];
|
||
const requiredExploits = [];
|
||
|
||
document.querySelectorAll('.req-deployment-select[data-dep-type="dependency"]').forEach((sel) => {
|
||
const depProjectId = sel.dataset.depProject;
|
||
const depDeploymentId = sel.value;
|
||
if (depProjectId && depDeploymentId)
|
||
requiredDeployments.push({
|
||
projectId: depProjectId,
|
||
deploymentId: depDeploymentId,
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.req-deployment-select[data-dep-type="exploit"]').forEach((sel) => {
|
||
const expProjectId = sel.dataset.exploitProject;
|
||
const expDeploymentId = sel.value;
|
||
if (expProjectId && expDeploymentId)
|
||
requiredExploits.push({
|
||
projectId: expProjectId,
|
||
deploymentId: expDeploymentId,
|
||
});
|
||
});
|
||
|
||
if (this.currentDeploymentMode === "add") {
|
||
const envId = document.getElementById("depEnvironment").value;
|
||
if (!envId) {
|
||
showToast("محیط را انتخاب کنید", "error");
|
||
return;
|
||
}
|
||
pr.deployments.push({
|
||
id: `dep-${Date.now()}`,
|
||
environmentId: envId,
|
||
version,
|
||
deploymentDate,
|
||
releaseNotes,
|
||
requiredDeployments,
|
||
requiredExploits, // 🆕
|
||
status: "active",
|
||
});
|
||
showToast(
|
||
`v${version} در ${this.getEnvironmentName(envId)} استقرار یافت`,
|
||
"success",
|
||
);
|
||
} else {
|
||
const depId = this.currentEditDeploymentId;
|
||
const dep = pr.deployments.find((d) => d.id === depId);
|
||
if (dep) {
|
||
dep.version = version;
|
||
dep.deploymentDate = deploymentDate;
|
||
dep.releaseNotes = releaseNotes;
|
||
dep.requiredExploits = requiredExploits;
|
||
dep.requiredDeployments = requiredDeployments;
|
||
showToast(`استقرار ویرایش شد`, "success");
|
||
}
|
||
}
|
||
ModalManager.close("deploymentModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
},
|
||
removeDeployment(productId, projId, depId) {
|
||
if (!confirm("⚠️ حذف کامل این استقرار؟\n\nاین عمل غیرقابل بازگشت است.\nاگر میخواهید فقط از لیست فعال خارج شود، از دکمه «آرشیو» استفاده کنید.")) return;
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
|
||
pr.deployments = (pr.deployments || []).filter((d) => d.id !== depId);
|
||
|
||
refreshUI();
|
||
this.showDetail(productId);
|
||
showToast("🗑️ استقرار بهطور کامل حذف شد", "warning");
|
||
},
|
||
archiveDeployment(productId, projId, depId) {
|
||
if (!confirm("📦 انتقال این استقرار به تاریخچه؟\n\nاستقرار از لیست فعال خارج شده و در بخش تاریخچه نگهداری میشود.")) return;
|
||
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const dep = (pr.deployments || []).find((d) => d.id === depId);
|
||
if (!dep) return;
|
||
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
|
||
dep.status = "archived";
|
||
dep.archivedAt = new Date().toISOString();
|
||
|
||
refreshUI();
|
||
this.showDetail(productId);
|
||
showToast("📦 استقرار به تاریخچه منتقل شد", "success");
|
||
},
|
||
removeProduct(productId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
if (!confirm(`حذف "${prod.title}"؟`)) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
projectData.appData.products = projectData.appData.products.filter(
|
||
(p) => p.id !== productId,
|
||
);
|
||
refreshUI();
|
||
ToolbarManager.populateOwnerFilter();
|
||
ModalManager.close("projectDetailModal");
|
||
showToast(`"${prod.title}" حذف شد`, "warning");
|
||
},
|
||
removeProject(productId, projId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
if (!confirm(`حذف "${pr.title}"؟`)) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
prod.projects = prod.projects.filter((p) => p.id !== projId);
|
||
refreshUI();
|
||
if (this.currentDetailProductId === productId)
|
||
this.showDetail(productId);
|
||
showToast(`"${pr.title}" حذف شد`, "warning");
|
||
},
|
||
toggleArchiveVisibility(projId) {
|
||
const list = document.getElementById(`archiveList-${projId}`);
|
||
if (!list) return;
|
||
const isHidden = list.style.display === "none";
|
||
list.style.display = isHidden ? "flex" : "none";
|
||
},
|
||
restoreDeployment(productId, projId, depId) {
|
||
if (!confirm("↩️ بازگرداندن این استقرار به لیست فعال؟")) return;
|
||
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const dep = (pr.deployments || []).find((d) => d.id === depId);
|
||
if (!dep) return;
|
||
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
|
||
// ✅ بازگرداندن به وضعیت active
|
||
dep.status = "active";
|
||
delete dep.archivedAt;
|
||
|
||
refreshUI();
|
||
this.showDetail(productId);
|
||
showToast("↩️ استقرار به لیست فعال بازگردانده شد", "success");
|
||
},
|
||
openExploitManager(productId, projId) {
|
||
this.currentExploitProjectId = productId;
|
||
this.currentExploitProjId = projId;
|
||
this.renderCurrentExploits();
|
||
this.renderAllProjectsForExploits();
|
||
this.setupExploitSearch();
|
||
ModalManager.open("exploitManagerModal");
|
||
},
|
||
renderCurrentExploits() {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentExploitProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentExploitProjId,
|
||
);
|
||
if (!pr) return;
|
||
const c = document.getElementById("currentExploitsList");
|
||
const exploits = pr.exploits || [];
|
||
if (exploits.length === 0) {
|
||
c.innerHTML = '<div class="empty-dependencies">هیچ سرویسی اضافه نشده است</div>';
|
||
return;
|
||
}
|
||
c.innerHTML = exploits
|
||
.map((expId, index) => {
|
||
const expPr = this.findProjectById(expId);
|
||
const projectName = expPr ? expPr.title : expId;
|
||
const typeInfo = expPr ? this.getTypeInfo(expPr.type) : null;
|
||
return `<div class="dependency-item"><div class="dependency-info"><div><div class="dependency-project-name">${typeInfo ? typeInfo.icon + " " : ""}${projectName}</div></div></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeExploit(${index})">🗑️</button></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
removeExploit(index) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentExploitProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentExploitProjId,
|
||
);
|
||
if (!pr) return;
|
||
if (!confirm("حذف این سرویس؟")) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
pr.exploits.splice(index, 1);
|
||
ModalManager.close("exploitManagerModal");
|
||
refreshUI();
|
||
if (this.currentDetailProductId)
|
||
this.showDetail(this.currentDetailProductId);
|
||
showToast("سرویس حذف شد", "success");
|
||
},
|
||
renderAllProjectsForExploits() {
|
||
const rc = document.getElementById("exploitSearchResults");
|
||
const cp = projectData.appData.products.find(
|
||
(p) => p.id === this.currentExploitProjectId,
|
||
);
|
||
if (!cp) return;
|
||
const cpr = cp.projects.find(
|
||
(p) => p.id === this.currentExploitProjId,
|
||
);
|
||
if (!cpr) return;
|
||
const allProjects = [];
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
if (pr.id !== cpr.id)
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
});
|
||
});
|
||
if (allProjects.length === 0) {
|
||
rc.innerHTML = '<div class="empty-dependencies">پروژه دیگری نیست</div>';
|
||
return;
|
||
}
|
||
rc.innerHTML = allProjects
|
||
.map((pr) => {
|
||
const isExploit = (cpr.exploits || []).includes(pr.id);
|
||
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addExploit('${pr.id}')" ${isExploit ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted);font-weight:normal">(${pr.productName})</small>${isExploit ? '<span style="margin-right:auto;font-size:10px;color:var(--success)">✓ استفاده میشود</span>' : ""}</button></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
setupExploitSearch() {
|
||
const si = document.getElementById("exploitSearch");
|
||
si.value = "";
|
||
si.oninput = (e) => {
|
||
const q = e.target.value.trim().toLowerCase();
|
||
if (q.length === 0) {
|
||
this.renderAllProjectsForExploits();
|
||
return;
|
||
}
|
||
const cp = projectData.appData.products.find(
|
||
(p) => p.id === this.currentExploitProjectId,
|
||
);
|
||
if (!cp) return;
|
||
const cpr = cp.projects.find(
|
||
(p) => p.id === this.currentExploitProjId,
|
||
);
|
||
if (!cpr) return;
|
||
const allProjects = [];
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
if (pr.id !== cpr.id)
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
});
|
||
});
|
||
const fl = allProjects.filter(
|
||
(pr) =>
|
||
pr.title.toLowerCase().includes(q) ||
|
||
pr.productName.toLowerCase().includes(q) ||
|
||
(pr.description || "").toLowerCase().includes(q),
|
||
);
|
||
const rc = document.getElementById("exploitSearchResults");
|
||
if (fl.length === 0) {
|
||
rc.innerHTML = '<div class="empty-dependencies">نتیجهای نیست</div>';
|
||
return;
|
||
}
|
||
rc.innerHTML = fl
|
||
.map((pr) => {
|
||
const isExploit = (cpr.exploits || []).includes(pr.id);
|
||
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addExploit('${pr.id}')" ${isExploit ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted)">(${pr.productName})</small></button></div>`;
|
||
})
|
||
.join("");
|
||
};
|
||
},
|
||
addExploit(projectId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentExploitProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentExploitProjId,
|
||
);
|
||
if (!pr) return;
|
||
if ((pr.exploits || []).includes(projectId)) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
if (!pr.exploits) pr.exploits = [];
|
||
pr.exploits.push(projectId);
|
||
ModalManager.close("exploitManagerModal");
|
||
refreshUI();
|
||
if (this.currentDetailProductId)
|
||
this.showDetail(this.currentDetailProductId);
|
||
showToast("سرویس اضافه شد", "success");
|
||
},
|
||
};
|
||
/* ==================== DASHBOARD MANAGER ==================== */
|
||
const DashboardManager = {
|
||
open() {
|
||
ModalManager.open("executiveDashboardModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
getReportData() {
|
||
const ps = projectData.appData.products || [];
|
||
let tp = 0,
|
||
td = 0,
|
||
tdp = 0;
|
||
const tc = {},
|
||
edc = {},
|
||
ppc = [],
|
||
fc = {};
|
||
let pwd = 0,
|
||
pwod = 0;
|
||
ps.forEach((prod) => {
|
||
const projects = prod.projects || [];
|
||
ppc.push({ title: prod.title, count: projects.length });
|
||
projects.forEach((pr) => {
|
||
tp++;
|
||
td += (pr.dependencies || []).length;
|
||
const active = (pr.deployments || []).filter(
|
||
(d) => d.status === "active",
|
||
);
|
||
tdp += active.length;
|
||
if (active.length > 0) pwd++;
|
||
else pwod++;
|
||
tc[pr.type] = (tc[pr.type] || 0) + 1;
|
||
const fw = pr.metadata?.framework || "نامشخص";
|
||
fc[fw] = (fc[fw] || 0) + 1;
|
||
active.forEach((d) => {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
edc[envName] = (edc[envName] || 0) + 1;
|
||
});
|
||
});
|
||
});
|
||
return {
|
||
products: ps.length,
|
||
projects: tp,
|
||
deps: td,
|
||
deploys: tdp,
|
||
environments: projectData.appData.environments.length,
|
||
tc,
|
||
edc,
|
||
ppc,
|
||
fc,
|
||
pwd,
|
||
pwod,
|
||
};
|
||
},
|
||
render() {
|
||
const c = document.getElementById("dashboardContainer");
|
||
if (!c) return;
|
||
const d = this.getReportData();
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
|
||
h += '</div><div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژهها</div><div class="dashboard-chart-body" id="dashTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="dashEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژهها</div><div class="dashboard-chart-body" id="dashStatusChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژهها به تفکیک محصول</div><div class="dashboard-chart-body" id="dashProductChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🛠️ توزیع فریمورکها</div><div class="dashboard-chart-body" id="dashFrameworkChart"></div></div>';
|
||
h += "</div>";
|
||
c.innerHTML = h;
|
||
this.renderDonut(
|
||
"dashTypeChart",
|
||
Object.entries(d.tc).map(([k, v]) => ({
|
||
label: this.getTypeLabel(k),
|
||
value: v,
|
||
color: this.getTypeColor(k),
|
||
})),
|
||
);
|
||
this.renderBar(
|
||
"dashEnvChart",
|
||
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
|
||
"#38a169",
|
||
);
|
||
this.renderDonut("dashStatusChart", [
|
||
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
|
||
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
|
||
]);
|
||
this.renderBar(
|
||
"dashProductChart",
|
||
d.ppc.map((p) => ({
|
||
label:
|
||
p.title.length > 18
|
||
? p.title.substring(0, 18) + "..."
|
||
: p.title,
|
||
value: p.count,
|
||
})),
|
||
"#3182ce",
|
||
);
|
||
this.renderBarH(
|
||
"dashFrameworkChart",
|
||
Object.entries(d.fc)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8)
|
||
.map(([k, v]) => ({
|
||
label: k.length > 14 ? k.substring(0, 14) + "..." : k,
|
||
value: v,
|
||
})),
|
||
);
|
||
const coverage =
|
||
d.pwd + d.pwod > 0
|
||
? Math.round((d.pwd / (d.pwd + d.pwod)) * 100)
|
||
: 0;
|
||
setTimeout(() => {
|
||
document.querySelectorAll("#dashboardContainer .dashboard-chart-body").forEach((body) => {
|
||
const svg = body.querySelector("svg");
|
||
if (svg) {
|
||
ChartZoomManager.enable(body, svg);
|
||
}
|
||
});
|
||
}, 200);
|
||
document.getElementById("dashboardFooterStats").innerHTML =
|
||
`<span>📦 محصولات: <span class="stat-highlight">${d.products}</span></span><span>🌿 پروژهها: <span class="stat-highlight">${d.projects}</span></span><span>🚀 استقرارها: <span class="stat-highlight">${d.deploys}</span></span><span>📈 پوشش استقرار: <span class="stat-highlight">${coverage}%</span></span>`;
|
||
},
|
||
renderDonut(cid, data) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((dd) => dd.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const total = data.reduce((s, dd) => s + dd.value, 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (dd) => dd.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, dd) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(dd));
|
||
showD3Tooltip(
|
||
e,
|
||
`${dd.data.label}: ${dd.data.value} (${Math.round((dd.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(total);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("پروژه");
|
||
},
|
||
renderBar(cid, data, color) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = 220,
|
||
m = { top: 20, right: 15, bottom: 45, left: 35 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(data.map((dd) => dd.label))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (dd) => dd.value) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-25)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll(".grid-line")
|
||
.data(y.ticks(4))
|
||
.join("line")
|
||
.attr("x1", 0)
|
||
.attr("x2", iw)
|
||
.attr("y1", (dd) => y(dd))
|
||
.attr("y2", (dd) => y(dd))
|
||
.attr("stroke", "var(--border-light)")
|
||
.attr("stroke-dasharray", "3,3");
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", (dd) => x(dd.label))
|
||
.attr("y", (dd) => y(dd.value))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (dd) => ih - y(dd.value))
|
||
.attr("fill", color)
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, dd) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", color);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (dd) => x(dd.label) + x.bandwidth() / 2)
|
||
.attr("y", (dd) => y(dd.value) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((dd) => dd.value);
|
||
},
|
||
renderBarH(cid, data) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>🛠️</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const bh = 28,
|
||
gap = 6;
|
||
const w = 320,
|
||
hgt = data.length * (bh + gap) + 30;
|
||
const m = { top: 10, right: 35, bottom: 10, left: 110 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (dd) => dd.value) || 1])
|
||
.range([0, iw]);
|
||
const y = d3
|
||
.scaleBand()
|
||
.domain(data.map((dd) => dd.label))
|
||
.range([0, ih])
|
||
.padding(0.2);
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (dd) => y(dd.label))
|
||
.attr("width", (dd) => x(dd.value))
|
||
.attr("height", y.bandwidth())
|
||
.attr("fill", "#2c5282")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, dd) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#2c5282");
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-weight", "600")
|
||
.text((dd) => dd.label);
|
||
g.selectAll(".value")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (dd) => x(dd.value) + 5)
|
||
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((dd) => dd.value);
|
||
},
|
||
getTypeLabel(t) {
|
||
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
|
||
return i ? i.label.split(" ")[0] : t;
|
||
},
|
||
getTypeColor(t) {
|
||
const c = {
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
};
|
||
return c[t] || "#718096";
|
||
},
|
||
};
|
||
/* ==================== CHART ZOOM MANAGER ==================== */
|
||
const ChartZoomManager = {
|
||
_zoomStates: new Map(),
|
||
|
||
/**
|
||
* فعالسازی zoom روی یک container
|
||
* @param {HTMLElement} container - عنصر dashboard-chart-body
|
||
* @param {SVGElement} svg - عنصر SVG داخلی
|
||
*/
|
||
enable(container, svg) {
|
||
if (!container || !svg || container.dataset.zoomEnabled === "true") return;
|
||
container.dataset.zoomEnabled = "true";
|
||
|
||
const state = { scale: 1, translateX: 0, translateY: 0, isPanning: false };
|
||
this._zoomStates.set(container, state);
|
||
|
||
// 🆕 ایجاد wrapper برای transform
|
||
const wrapper = document.createElement("div");
|
||
wrapper.className = "chart-zoom-container";
|
||
wrapper.style.width = "100%";
|
||
wrapper.style.height = "100%";
|
||
wrapper.style.display = "flex";
|
||
wrapper.style.justifyContent = "center";
|
||
wrapper.style.alignItems = "center";
|
||
|
||
// انتقال SVG به wrapper
|
||
while (container.firstChild) {
|
||
wrapper.appendChild(container.firstChild);
|
||
}
|
||
container.appendChild(wrapper);
|
||
|
||
// 🆕 ایجاد نشانگر zoom
|
||
const indicator = document.createElement("div");
|
||
indicator.className = "chart-zoom-indicator";
|
||
indicator.textContent = "🔍 100%";
|
||
container.appendChild(indicator);
|
||
|
||
// 🆕 ایجاد دکمه reset
|
||
const resetBtn = document.createElement("button");
|
||
resetBtn.className = "chart-zoom-reset";
|
||
resetBtn.textContent = "🎯 بازنشانی";
|
||
resetBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.resetZoom(container);
|
||
};
|
||
container.appendChild(resetBtn);
|
||
|
||
// 🆕 event listener برای wheel (zoom)
|
||
container.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
const newScale = Math.max(0.5, Math.min(5, state.scale * delta));
|
||
|
||
state.scale = newScale;
|
||
this._applyTransform(container, wrapper, state);
|
||
|
||
indicator.textContent = `🔍 ${Math.round(newScale * 100)}%`;
|
||
indicator.classList.add("visible");
|
||
resetBtn.classList.toggle("visible", newScale !== 1);
|
||
|
||
clearTimeout(state._hideTimer);
|
||
state._hideTimer = setTimeout(() => {
|
||
indicator.classList.remove("visible");
|
||
}, 1500);
|
||
}, { passive: false });
|
||
|
||
// 🆕 event listener برای pan (drag)
|
||
container.addEventListener("mousedown", (e) => {
|
||
if (e.button !== 0) return;
|
||
if (state.scale <= 1) return; // فقط وقتی zoom شده
|
||
state.isPanning = true;
|
||
state._startX = e.clientX;
|
||
state._startY = e.clientY;
|
||
state._startTranslateX = state.translateX;
|
||
state._startTranslateY = state.translateY;
|
||
wrapper.style.cursor = "grabbing";
|
||
});
|
||
|
||
window.addEventListener("mousemove", (e) => {
|
||
if (!state.isPanning) return;
|
||
const dx = e.clientX - state._startX;
|
||
const dy = e.clientY - state._startY;
|
||
state.translateX = state._startTranslateX + dx;
|
||
state.translateY = state._startTranslateY + dy;
|
||
this._applyTransform(container, wrapper, state);
|
||
});
|
||
|
||
window.addEventListener("mouseup", () => {
|
||
if (state.isPanning) {
|
||
state.isPanning = false;
|
||
wrapper.style.cursor = state.scale > 1 ? "grab" : "default";
|
||
}
|
||
});
|
||
|
||
// 🆕 dblclick برای reset
|
||
container.addEventListener("dblclick", (e) => {
|
||
if (e.target === svg || e.target === container) {
|
||
this.resetZoom(container);
|
||
}
|
||
});
|
||
},
|
||
|
||
_applyTransform(container, wrapper, state) {
|
||
wrapper.style.transform =
|
||
`translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
|
||
},
|
||
|
||
resetZoom(container) {
|
||
const state = this._zoomStates.get(container);
|
||
if (!state) return;
|
||
state.scale = 1;
|
||
state.translateX = 0;
|
||
state.translateY = 0;
|
||
|
||
const wrapper = container.querySelector(".chart-zoom-container");
|
||
if (wrapper) {
|
||
this._applyTransform(container, wrapper, state);
|
||
wrapper.style.cursor = "default";
|
||
}
|
||
|
||
const indicator = container.querySelector(".chart-zoom-indicator");
|
||
const resetBtn = container.querySelector(".chart-zoom-reset");
|
||
if (indicator) {
|
||
indicator.textContent = "🔍 100%";
|
||
indicator.classList.add("visible");
|
||
setTimeout(() => indicator.classList.remove("visible"), 1500);
|
||
}
|
||
if (resetBtn) resetBtn.classList.remove("visible");
|
||
}
|
||
};
|
||
/* ==================== GALAXY CHART ==================== */
|
||
const GalaxyChartManager = {
|
||
svg: null,
|
||
zoomBehavior: null,
|
||
zoomGroup: null,
|
||
nodeElements: null,
|
||
linkElements: null,
|
||
labelElements: null,
|
||
allNodes: [],
|
||
collapsedProducts: new Set(),
|
||
NODE_COLORS: {
|
||
product: "#c9a961",
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
},
|
||
open() {
|
||
ModalManager.open("galaxyChartModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
buildGraphData() {
|
||
const nodes = [], links = [];
|
||
const projectToProduct = new Map();
|
||
|
||
projectData.appData.products.forEach((prod) => {
|
||
(prod.projects || []).forEach((pr) => {
|
||
projectToProduct.set(pr.id, prod.id);
|
||
});
|
||
});
|
||
|
||
projectData.appData.products.forEach((prod) => {
|
||
const isCollapsed = this.collapsedProducts.has(prod.id);
|
||
nodes.push({
|
||
id: prod.id,
|
||
label: prod.title,
|
||
type: "product",
|
||
radius: 30,
|
||
data: prod,
|
||
collapsed: isCollapsed,
|
||
collapsedCount: isCollapsed ? (prod.projects || []).length : 0,
|
||
});
|
||
if (!isCollapsed) {
|
||
(prod.projects || []).forEach((pr) => {
|
||
nodes.push({
|
||
id: pr.id,
|
||
label: pr.title,
|
||
type: "project",
|
||
projectType: pr.type,
|
||
radius: 16,
|
||
data: pr,
|
||
productTitle: prod.title,
|
||
productId: prod.id,
|
||
});
|
||
links.push({
|
||
source: prod.id,
|
||
target: pr.id,
|
||
type: "ownership",
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
// 🆕 پردازش dependencies
|
||
projectData.appData.products.forEach((prod) => {
|
||
(prod.projects || []).forEach((pr) => {
|
||
(pr.dependencies || []).forEach((depId) => {
|
||
let sourceId = pr.id, targetId = depId;
|
||
const sourceProductId = projectToProduct.get(pr.id);
|
||
if (sourceProductId && this.collapsedProducts.has(sourceProductId))
|
||
sourceId = sourceProductId;
|
||
const targetProductId = projectToProduct.get(depId);
|
||
if (targetProductId && this.collapsedProducts.has(targetProductId))
|
||
targetId = targetProductId;
|
||
if (sourceId === targetId) return;
|
||
const exists = links.some((l) => {
|
||
const s = typeof l.source === "object" ? l.source.id : l.source;
|
||
const t = typeof l.target === "object" ? l.target.id : l.target;
|
||
return s === sourceId && t === targetId && l.type === "dependency";
|
||
});
|
||
if (!exists)
|
||
links.push({
|
||
source: sourceId,
|
||
target: targetId,
|
||
type: "dependency",
|
||
});
|
||
});
|
||
|
||
// 🆕 پردازش exploits
|
||
(pr.exploits || []).forEach((expId) => {
|
||
let sourceId = pr.id, targetId = expId;
|
||
const sourceProductId = projectToProduct.get(pr.id);
|
||
if (sourceProductId && this.collapsedProducts.has(sourceProductId))
|
||
sourceId = sourceProductId;
|
||
const targetProductId = projectToProduct.get(expId);
|
||
if (targetProductId && this.collapsedProducts.has(targetProductId))
|
||
targetId = targetProductId;
|
||
if (sourceId === targetId) return;
|
||
const exists = links.some((l) => {
|
||
const s = typeof l.source === "object" ? l.source.id : l.source;
|
||
const t = typeof l.target === "object" ? l.target.id : l.target;
|
||
return s === sourceId && t === targetId && l.type === "exploit";
|
||
});
|
||
if (!exists)
|
||
links.push({
|
||
source: sourceId,
|
||
target: targetId,
|
||
type: "exploit", // 🆕 نوع جدید
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
return { nodes, links };
|
||
},
|
||
toggleCollapseProduct(productId) {
|
||
if (this.collapsedProducts.has(productId))
|
||
this.collapsedProducts.delete(productId);
|
||
else this.collapsedProducts.add(productId);
|
||
this.render();
|
||
},
|
||
collapseAllProducts() {
|
||
projectData.appData.products.forEach((prod) => {
|
||
this.collapsedProducts.add(prod.id);
|
||
});
|
||
this.render();
|
||
showToast(
|
||
`⊟ ${projectData.appData.products.length} محصول کولاپس شدند`,
|
||
"info",
|
||
2000,
|
||
);
|
||
},
|
||
expandAllProducts() {
|
||
const count = this.collapsedProducts.size;
|
||
this.collapsedProducts.clear();
|
||
this.render();
|
||
showToast(`⊞ ${count} محصول از حالت کولاپس خارج شدند`, "info", 2000);
|
||
},
|
||
render() {
|
||
const c = document.getElementById("galaxyChartContainer");
|
||
c.innerHTML = "";
|
||
const w = c.clientWidth || 900,
|
||
h = c.clientHeight || 600;
|
||
const { nodes, links } = this.buildGraphData();
|
||
this.allNodes = nodes;
|
||
if (nodes.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="galaxy-empty"><div style="font-size:60px">🌌</div><div>هیچ دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", h)
|
||
.attr("id", "galaxySvg");
|
||
this.svg = svg;
|
||
svg
|
||
.append("rect")
|
||
.attr("width", w)
|
||
.attr("height", h)
|
||
.attr("fill", "#0a0e27");
|
||
for (let i = 0; i < 100; i++) {
|
||
svg
|
||
.append("circle")
|
||
.attr("cx", Math.random() * w)
|
||
.attr("cy", Math.random() * h)
|
||
.attr("r", Math.random() + 0.3)
|
||
.attr("fill", "#fff")
|
||
.attr("opacity", Math.random() * 0.5 + 0.1);
|
||
}
|
||
const defs = svg.append("defs");
|
||
const gf = defs.append("filter").attr("id", "galaxyGlow");
|
||
gf.append("feGaussianBlur")
|
||
.attr("stdDeviation", "3")
|
||
.attr("result", "blur");
|
||
const fm = gf.append("feMerge");
|
||
fm.append("feMergeNode").attr("in", "blur");
|
||
fm.append("feMergeNode").attr("in", "SourceGraphic");
|
||
|
||
defs.append("marker")
|
||
.attr("id", "arrowDependency")
|
||
.attr("viewBox", "0 -5 10 10")
|
||
.attr("refX", 10) // نقطه مرجع در نوک فلش
|
||
.attr("refY", 0)
|
||
.attr("markerWidth", 8) // عرض فلش
|
||
.attr("markerHeight", 8) // ارتفاع فلش
|
||
.attr("orient", "auto")
|
||
.attr("markerUnits", "userSpaceOnUse")
|
||
.append("path")
|
||
.attr("d", "M0,-5L10,0L0,5") // شکل مثلثی فلش
|
||
.attr("fill", "#e0c789"); // رنگ طلایی هماهنگ با لینک وابستگی
|
||
|
||
defs.append("marker")
|
||
.attr("id", "arrowExploit")
|
||
.attr("viewBox", "0 -5 10 10")
|
||
.attr("refX", 10)
|
||
.attr("refY", 0)
|
||
.attr("markerWidth", 7)
|
||
.attr("markerHeight", 7)
|
||
.attr("orient", "auto")
|
||
.attr("markerUnits", "userSpaceOnUse")
|
||
.append("path")
|
||
.attr("d", "M0,-4L10,0L0,4")
|
||
.attr("fill", "#4299e1");
|
||
|
||
// 🆕 Marker برای لینکهای ownership (اختیاری - بدون فلش یا فلش کوچکتر)
|
||
defs.append("marker")
|
||
.attr("id", "arrowOwnership")
|
||
.attr("viewBox", "0 -5 10 10")
|
||
.attr("refX", 10)
|
||
.attr("refY", 0)
|
||
.attr("markerWidth", 6)
|
||
.attr("markerHeight", 6)
|
||
.attr("orient", "auto")
|
||
.attr("markerUnits", "userSpaceOnUse")
|
||
.append("path")
|
||
.attr("d", "M0,-4L10,0L0,4")
|
||
.attr("fill", "#4a5568")
|
||
.attr("opacity", 0.7);
|
||
|
||
this.zoomGroup = svg.append("g");
|
||
this.zoomBehavior = d3
|
||
.zoom()
|
||
.scaleExtent([0.2, 5])
|
||
.on("zoom", (e) => {
|
||
this.zoomGroup.attr("transform", e.transform);
|
||
document.getElementById("galaxyZoomIndicator").textContent =
|
||
`🔍 ${Math.round(e.transform.k * 100)}%`;
|
||
});
|
||
svg.call(this.zoomBehavior);
|
||
const sim = d3
|
||
.forceSimulation(nodes)
|
||
.force(
|
||
"link",
|
||
d3
|
||
.forceLink(links)
|
||
.id((d) => d.id)
|
||
.distance((d) => (d.type === "ownership" ? 100 : 160)),
|
||
)
|
||
.force("charge", d3.forceManyBody().strength(-300))
|
||
.force("center", d3.forceCenter(w / 2, h / 2));
|
||
this.linkElements = this.zoomGroup
|
||
.append("g")
|
||
.selectAll("line")
|
||
.data(links)
|
||
.join("line")
|
||
.attr("stroke", (d) => {
|
||
if (d.type === "dependency") return "#e0c789";
|
||
if (d.type === "exploit") return "#4299e1"; // 🆕 رنگ آبی
|
||
return "#4a5568";
|
||
})
|
||
.attr("stroke-width", (d) => (d.type === "dependency" ? 2 : 1))
|
||
.attr("stroke-opacity", (d) => {
|
||
if (d.type === "dependency") return 0.85;
|
||
if (d.type === "exploit") return 0.6; // 🆕
|
||
return 0.4;
|
||
})
|
||
.attr("stroke-dasharray", (d) => {
|
||
if (d.type === "dependency") return "5,3";
|
||
if (d.type === "exploit") return "2,4"; // 🆕 چینش متفاوت
|
||
return "none";
|
||
})
|
||
.attr("marker-end", (d) => {
|
||
if (d.type === "dependency") return "url(#arrowDependency)";
|
||
if (d.type === "exploit") return "url(#arrowExploit)"; // 🆕
|
||
return "url(#arrowOwnership)";
|
||
});
|
||
this.linkElements
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this)
|
||
.attr("stroke-opacity", 1)
|
||
.attr("stroke-width", d.type === "dependency" ? 3 : 2);
|
||
const sourceNode = this.__data__.source;
|
||
const targetNode = this.__data__.target;
|
||
const sourceLabel = sourceNode.label || sourceNode.id;
|
||
const targetLabel = targetNode.label || targetNode.id;
|
||
let tooltipText = "";
|
||
if (d.type === "dependency") {
|
||
tooltipText = `🔗 ${sourceLabel} → ${targetLabel} (وابستگی)`;
|
||
} else if (d.type === "exploit") {
|
||
tooltipText = `🔌 ${sourceLabel} → ${targetLabel} (استفاده از سرویس)`;
|
||
} else {
|
||
tooltipText = `📦 ${targetLabel} شامل ${sourceLabel}`;
|
||
}
|
||
showD3Tooltip(e, tooltipText);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, d) {
|
||
d3.select(this)
|
||
.attr("stroke-opacity", d.type === "dependency" ? 0.85 : 0.4)
|
||
.attr("stroke-width", d.type === "dependency" ? 2 : 1);
|
||
hideD3Tooltip();
|
||
});
|
||
const node = this.zoomGroup
|
||
.append("g")
|
||
.selectAll("g")
|
||
.data(nodes)
|
||
.join("g")
|
||
.style("cursor", "pointer")
|
||
.call(
|
||
d3
|
||
.drag()
|
||
.on("start", (e, d) => {
|
||
if (!e.active) sim.alphaTarget(0.3).restart();
|
||
d.fx = d.x;
|
||
d.fy = d.y;
|
||
})
|
||
.on("drag", (e, d) => {
|
||
d.fx = e.x;
|
||
d.fy = e.y;
|
||
})
|
||
.on("end", (e, d) => {
|
||
if (!e.active) sim.alphaTarget(0);
|
||
d.fx = null;
|
||
d.fy = null;
|
||
}),
|
||
);
|
||
this.nodeElements = node;
|
||
node
|
||
.append("circle")
|
||
.attr("r", (d) => d.radius)
|
||
.attr("fill", (d) =>
|
||
d.type === "product"
|
||
? this.NODE_COLORS.product
|
||
: this.NODE_COLORS[d.projectType] || "#718096",
|
||
)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 1.5)
|
||
.attr("filter", "url(#galaxyGlow)");
|
||
node
|
||
.append("text")
|
||
.attr("dy", (d) => d.radius + 14)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "#fff")
|
||
.attr("font-size", "10px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.text((d) => d.label);
|
||
node
|
||
.filter((d) => d.type === "product" && d.collapsed)
|
||
.append("text")
|
||
.attr("class", "galaxy-collapse-badge")
|
||
.attr("dy", 4)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "#fff")
|
||
.attr("font-size", "11px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => `+${d.collapsedCount}`);
|
||
node.on("click", (e, d) => {
|
||
this.showNodeInfo(d);
|
||
});
|
||
node.on("dblclick", (e, d) => {
|
||
if (d.type === "product") {
|
||
e.stopPropagation();
|
||
this.toggleCollapseProduct(d.id);
|
||
}
|
||
});
|
||
|
||
const getEdgePoint = (source, target, targetRadius) => {
|
||
const dx = target.x - source.x;
|
||
const dy = target.y - source.y;
|
||
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const offset = targetRadius + 10;
|
||
|
||
return {
|
||
x: target.x - (dx / distance) * offset,
|
||
y: target.y - (dy / distance) * offset,
|
||
};
|
||
};
|
||
|
||
sim.on("tick", () => {
|
||
this.linkElements
|
||
.attr("x1", (d) => {
|
||
// نقطه شروع: روی مرز گره منبع
|
||
const dx = d.target.x - d.source.x;
|
||
const dy = d.target.y - d.source.y;
|
||
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
return d.source.x + (dx / distance) * d.source.radius;
|
||
})
|
||
.attr("y1", (d) => {
|
||
const dx = d.target.x - d.source.x;
|
||
const dy = d.target.y - d.source.y;
|
||
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
return d.source.y + (dy / distance) * d.source.radius;
|
||
})
|
||
.attr("x2", (d) => {
|
||
// نقطه پایان: روی مرز گره هدف (با offset برای فلش)
|
||
const edge = getEdgePoint(d.source, d.target, d.target.radius);
|
||
return edge.x;
|
||
})
|
||
.attr("y2", (d) => {
|
||
const edge = getEdgePoint(d.source, d.target, d.target.radius);
|
||
return edge.y;
|
||
});
|
||
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
||
});
|
||
|
||
this.renderLegend();
|
||
this.updateStats(nodes, links);
|
||
this.setupSearch();
|
||
},
|
||
getTypeLabel(t) {
|
||
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
|
||
return i ? i.label.split(" ")[0] : t;
|
||
},
|
||
renderLegend() {
|
||
const l = document.getElementById("galaxyLegend");
|
||
let h = '<div class="galaxy-legend-title">🎨 راهنما</div>';
|
||
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS.product}"></div><span>📦 محصول</span></div>`;
|
||
VALID_PROJECT_TYPES.forEach((t) => {
|
||
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS[t.value]}"></div><span>${t.icon} ${t.label.split(" ")[0]}</span></div>`;
|
||
});
|
||
h += `<div class="galaxy-legend-item" style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.2)"><div style="width:14px;height:2px;background:#e0c789"></div><span>وابستگی سخت</span></div>`;
|
||
h += `<div class="galaxy-legend-item"><div style="width:14px;height:2px;background:#4299e1;border-style:dashed"></div><span>استفاده از سرویس</span></div>`;
|
||
l.innerHTML = h;
|
||
},
|
||
updateStats(nodes, links) {
|
||
const p = projectData.appData.products.length;
|
||
const pr = projectData.appData.products.reduce(
|
||
(x, p) => x + p.projects.length,
|
||
0,
|
||
);
|
||
const dp = links.filter((l) => l.type === "dependency").length;
|
||
document.getElementById("galaxyStats").innerHTML =
|
||
`<span>📦 ${p}</span><span>🌿 ${pr}</span><span>🔗 ${dp}</span>`;
|
||
},
|
||
showNodeInfo(d) {
|
||
const p = document.getElementById("galaxyInfoPanel");
|
||
const ic = document.getElementById("galaxyInfoContent");
|
||
let ih = "";
|
||
if (d.type === "product") {
|
||
const projects = d.data.projects || [];
|
||
const isCollapsed = this.collapsedProducts.has(d.id);
|
||
ih += `<h4>📦 ${d.label}</h4>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">توضیحات</span><span class="galaxy-info-value">${d.data.description || "—"}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">مالک</span><span class="galaxy-info-value">${d.data.metadata?.owner || "—"}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">تعداد پروژه</span><span class="galaxy-info-value">${projects.length}</span></div>`;
|
||
if (projects.length > 0) {
|
||
ih += `<div style="margin-top:10px;border-top:1px solid rgba(255,255,255,.1);padding-top:8px"><div style="font-weight:700;font-size:11px;margin-bottom:6px">🌿 پروژهها:</div>`;
|
||
projects.forEach((pr) => {
|
||
const ti = this.getTypeInfo(pr.type);
|
||
ih += `<div style="font-size:11px;padding:3px 0;color:rgba(255,255,255,.8)">${ti.icon} ${pr.title}</div>`;
|
||
});
|
||
ih += `</div>`;
|
||
}
|
||
} else {
|
||
const ti = this.getTypeInfo(d.projectType);
|
||
const deploys = ProductManager.getActiveDeployments(d.data);
|
||
ih += `<h4>${ti.icon} ${d.label}</h4>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">نوع</span><span class="galaxy-info-value">${ti.label}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">فریمورک</span><span class="galaxy-info-value">${d.data.metadata?.framework || "—"}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">استقرارها</span><span class="galaxy-info-value">${deploys.length}</span></div>`;
|
||
}
|
||
ic.innerHTML = ih;
|
||
p.classList.add("visible");
|
||
},
|
||
getTypeInfo(type) {
|
||
return (
|
||
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
|
||
value: type,
|
||
label: type,
|
||
icon: "❓",
|
||
}
|
||
);
|
||
},
|
||
setupSearch() {
|
||
const inp = document.getElementById("galaxySearchInput");
|
||
const clr = document.getElementById("galaxySearchClear");
|
||
if (!inp || !clr) return;
|
||
inp.value = "";
|
||
clr.classList.remove("visible");
|
||
inp.oninput = () => this.applySearch(inp.value.trim());
|
||
inp.onkeydown = (e) => {
|
||
if (e.key === "Escape" && inp.value) {
|
||
e.stopPropagation();
|
||
this.clearSearch();
|
||
inp.focus();
|
||
}
|
||
};
|
||
clr.onclick = () => {
|
||
this.clearSearch();
|
||
inp.focus();
|
||
};
|
||
},
|
||
applySearch(query) {
|
||
const clr = document.getElementById("galaxySearchClear");
|
||
const results = document.getElementById("galaxySearchResults");
|
||
if (clr) clr.classList.toggle("visible", !!query);
|
||
if (!query) {
|
||
this.clearSearch();
|
||
return;
|
||
}
|
||
const q = query.toLowerCase();
|
||
const matches = this.allNodes.filter((n) => {
|
||
const hay = [
|
||
n.label,
|
||
n.productTitle || "",
|
||
n.data?.description || "",
|
||
n.data?.metadata?.framework || "",
|
||
n.data?.metadata?.language || "",
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
const matchIds = new Set(matches.map((m) => m.id));
|
||
if (this.nodeElements) {
|
||
this.nodeElements
|
||
.classed("galaxy-dim", (d) => !matchIds.has(d.id))
|
||
.classed("galaxy-match", (d) => matchIds.has(d.id));
|
||
}
|
||
if (this.linkElements) {
|
||
this.linkElements.classed("galaxy-dim", (l) => {
|
||
const s = typeof l.source === "object" ? l.source.id : l.source;
|
||
const t = typeof l.target === "object" ? l.target.id : l.target;
|
||
return !(matchIds.has(s) || matchIds.has(t));
|
||
});
|
||
}
|
||
if (!results) return;
|
||
if (matches.length === 0) {
|
||
results.innerHTML =
|
||
'<div class="galaxy-search-empty">نتیجهای یافت نشد</div>';
|
||
} else {
|
||
results.innerHTML = matches
|
||
.slice(0, 30)
|
||
.map((n) => {
|
||
const icon =
|
||
n.type === "product"
|
||
? "📦"
|
||
: ProductManager.getTypeInfo(n.projectType)?.icon || "❓";
|
||
const sub =
|
||
n.type === "product"
|
||
? "محصول"
|
||
: ProductManager.getTypeInfo(n.projectType)?.label || "";
|
||
return `<div class="galaxy-search-result-item" data-node-id="${n.id}"><span class="galaxy-search-result-icon">${icon}</span><div class="galaxy-search-result-text"><div class="galaxy-search-result-title">${n.label}</div><div class="galaxy-search-result-sub">${sub}</div></div></div>`;
|
||
})
|
||
.join("");
|
||
results
|
||
.querySelectorAll(".galaxy-search-result-item")
|
||
.forEach((item) => {
|
||
item.addEventListener("click", () => {
|
||
this.focusNode(item.dataset.nodeId);
|
||
results.classList.remove("visible");
|
||
});
|
||
});
|
||
}
|
||
results.classList.add("visible");
|
||
},
|
||
clearSearch() {
|
||
const inp = document.getElementById("galaxySearchInput");
|
||
const clr = document.getElementById("galaxySearchClear");
|
||
const results = document.getElementById("galaxySearchResults");
|
||
if (inp) inp.value = "";
|
||
if (clr) clr.classList.remove("visible");
|
||
if (results) {
|
||
results.innerHTML = "";
|
||
results.classList.remove("visible");
|
||
}
|
||
if (this.nodeElements)
|
||
this.nodeElements
|
||
.classed("galaxy-dim", false)
|
||
.classed("galaxy-match", false)
|
||
.classed("galaxy-focus", false);
|
||
if (this.linkElements) this.linkElements.classed("galaxy-dim", false);
|
||
},
|
||
focusNode(nodeId) {
|
||
const n = this.allNodes.find((x) => x.id === nodeId);
|
||
if (!n || !this.svg || !this.zoomBehavior) return;
|
||
const c = document.getElementById("galaxyChartContainer");
|
||
const w = c.clientWidth || 900,
|
||
h = c.clientHeight || 600;
|
||
const scale = 1.6;
|
||
const t = d3.zoomIdentity
|
||
.translate(w / 2 - scale * (n.x || 0), h / 2 - scale * (n.y || 0))
|
||
.scale(scale);
|
||
this.svg
|
||
.transition()
|
||
.duration(600)
|
||
.call(this.zoomBehavior.transform, t);
|
||
this.showNodeInfo(n);
|
||
if (this.nodeElements) {
|
||
this.nodeElements.classed("galaxy-focus", (d) => d.id === nodeId);
|
||
setTimeout(() => {
|
||
if (this.nodeElements)
|
||
this.nodeElements.classed("galaxy-focus", false);
|
||
}, 2000);
|
||
}
|
||
},
|
||
exportPNG() {
|
||
const svg = this.svg;
|
||
if (!svg) {
|
||
showToast("❌ نموداری برای خروجی وجود ندارد", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 🆕 کپی از SVG اصلی
|
||
const svgNode = svg.node();
|
||
const clone = svgNode.cloneNode(true);
|
||
|
||
// 🆕 تنظیم ابعاد
|
||
const width = svgNode.clientWidth || 1200;
|
||
const height = svgNode.clientHeight || 800;
|
||
clone.setAttribute("width", width);
|
||
clone.setAttribute("height", height);
|
||
clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||
|
||
// 🆕 اضافه کردن xmlns (الزامی برای تبدیل به تصویر)
|
||
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||
|
||
// 🆕 تبدیل style های inline به attributes (برای سازگاری)
|
||
clone.querySelectorAll("*").forEach((el) => {
|
||
const computedStyle = window.getComputedStyle(
|
||
svgNode.querySelector(`[class="${el.getAttribute("class")}"]`) || el
|
||
);
|
||
// حفظ font-family برای متنها
|
||
if (el.tagName === "text") {
|
||
el.setAttribute("font-family", "Vazirmatn, Tahoma, sans-serif");
|
||
}
|
||
});
|
||
|
||
// 🆕 serialize به string
|
||
const serializer = new XMLSerializer();
|
||
let svgString = serializer.serializeToString(clone);
|
||
|
||
// 🆕 اضافه کردن declaration
|
||
svgString = '<?xml version="1.0" standalone="no"?>\r\n' + svgString;
|
||
|
||
// 🆕 تبدیل به data URL
|
||
const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||
const url = URL.createObjectURL(svgBlob);
|
||
|
||
// 🆕 ایجاد Image و تبدیل به Canvas
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width * 2; // 🆕 کیفیت 2x برای وضوح بیشتر
|
||
canvas.height = height * 2;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.scale(2, 2);
|
||
ctx.fillStyle = "#0a0e27"; // 🆕 پسزمینه تیره نمودار کهکشانی
|
||
ctx.fillRect(0, 0, width, height);
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 🆕 دانلود فایل
|
||
canvas.toBlob((blob) => {
|
||
if (!blob) {
|
||
showToast("❌ خطا در تولید تصویر", "error");
|
||
return;
|
||
}
|
||
const pngUrl = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = pngUrl;
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||
a.download = `x-release-assistant-chart-${timestamp}.png`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(pngUrl);
|
||
URL.revokeObjectURL(url);
|
||
|
||
showToast("📷 خروجی PNG با موفقیت ذخیره شد", "success");
|
||
}, "image/png", 1.0);
|
||
};
|
||
|
||
img.onerror = () => {
|
||
showToast("❌ خطا در بارگذاری SVG", "error");
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
img.src = url;
|
||
} catch (err) {
|
||
showToast("❌ خطا: " + err.message, "error");
|
||
}
|
||
},
|
||
};
|
||
/* ==================== AUTOSAVE MANAGER ==================== */
|
||
const AutosaveManager = {
|
||
STORAGE_KEY: "xrelease-appdata-autosave",
|
||
saveTimer: null,
|
||
lastSaveTime: null,
|
||
DEBOUNCE_MS: 1500,
|
||
scheduleSave() {
|
||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||
this.saveTimer = setTimeout(() => this.save(), this.DEBOUNCE_MS);
|
||
},
|
||
save() {
|
||
try {
|
||
const data = {
|
||
version: projectData.version || "1.2.0",
|
||
appData: JSON.parse(JSON.stringify(projectData.appData)),
|
||
activeBoardId: ReleaseBoardsManager?.activeBoardId || null,
|
||
savedAt: new Date().toISOString(),
|
||
};
|
||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
|
||
this.lastSaveTime = new Date();
|
||
this.showIndicator();
|
||
} catch (e) {
|
||
console.error("Autosave failed:", e);
|
||
}
|
||
},
|
||
load() {
|
||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||
if (!saved) return null;
|
||
try {
|
||
return JSON.parse(saved);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
},
|
||
clear() {
|
||
localStorage.removeItem(this.STORAGE_KEY);
|
||
},
|
||
showIndicator() {
|
||
const indicator = document.getElementById("rbAutosaveIndicator");
|
||
const text = document.getElementById("rbAutosaveText");
|
||
if (!indicator || !text) return;
|
||
text.textContent = "✓ ذخیره خودکار انجام شد";
|
||
indicator.style.opacity = "1";
|
||
setTimeout(() => {
|
||
indicator.style.opacity = "0";
|
||
}, 2000);
|
||
},
|
||
checkRecovery() {
|
||
const saved = this.load();
|
||
if (!saved || !saved.appData) return;
|
||
|
||
document.getElementById("rbRecoverAutosaveBtn").onclick = () =>
|
||
this.recover();
|
||
document.getElementById("rbDiscardAutosaveBtn").onclick = () =>
|
||
this.discard();
|
||
|
||
setTimeout(() => {
|
||
ModalManager.open("rbAutosaveModal");
|
||
}, 600);
|
||
},
|
||
recover() {
|
||
const saved = this.load();
|
||
if (!saved || !saved.appData) {
|
||
ModalManager.close("rbAutosaveModal");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
projectData.appData = saved.appData;
|
||
if (saved.activeBoardId && ReleaseBoardsManager)
|
||
ReleaseBoardsManager.activeBoardId = saved.activeBoardId;
|
||
this.clear();
|
||
ModalManager.close("rbAutosaveModal");
|
||
updateProjectUI();
|
||
renderToolGroups();
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
ToolbarManager.populateOwnerFilter();
|
||
refreshUI();
|
||
if (ReleaseBoardsManager) {
|
||
ReleaseBoardsManager.render(true);
|
||
ReleaseBoardsManager.updateUndoRedoButtons();
|
||
}
|
||
showToast("✅ دادههای خودکار با موفقیت بازیابی شدند", "success");
|
||
},
|
||
discard() {
|
||
this.clear();
|
||
ModalManager.close("rbAutosaveModal");
|
||
showToast("🗑️ دادههای خودکار نادیده گرفته شدند", "info");
|
||
},
|
||
};
|
||
/* ==================== RELEASE BOARDS MANAGER ==================== */
|
||
const ReleaseBoardsManager = {
|
||
activeBoardId: null,
|
||
editing: {
|
||
boardId: null,
|
||
columnId: null,
|
||
cardId: null,
|
||
columnForCard: null,
|
||
},
|
||
tempData: {},
|
||
_tabsCache: "",
|
||
_dateCache: new Map(),
|
||
_eventsBound: false,
|
||
_lastUndoRedoState: { undo: -1, redo: -1 },
|
||
_renderCache: { boardId: null, columnsHash: "" },
|
||
filters: { search: "", tags: [] },
|
||
dragType: null,
|
||
dragCardId: null,
|
||
dragColumnId: null,
|
||
DEFAULT_COLUMNS: [
|
||
{
|
||
title: "📥 بک لاگ",
|
||
backgroundColor: "#6b7208",
|
||
textColor: "#b4cbf9",
|
||
},
|
||
{
|
||
title: "📋 برای انجام",
|
||
backgroundColor: "#393e65",
|
||
textColor: "#b4cbf9",
|
||
},
|
||
{
|
||
title: "🔨 در حال انجام",
|
||
backgroundColor: "#453111",
|
||
textColor: "#b4cbf9",
|
||
},
|
||
{
|
||
title: "🔍 برای تست",
|
||
backgroundColor: "#542881",
|
||
textColor: "#b4cbf9",
|
||
},
|
||
{
|
||
title: "✅ انجام شده",
|
||
backgroundColor: "#087224",
|
||
textColor: "#d8d96d",
|
||
},
|
||
],
|
||
open() {
|
||
ModalManager.open("releaseBoardsModal");
|
||
setTimeout(() => {
|
||
const boards = projectData.appData.boards || [];
|
||
if (boards.length > 0 && !this.activeBoardId)
|
||
this.activeBoardId = boards[0].id;
|
||
this.render(true);
|
||
this.bindFormEvents();
|
||
this.renderFilterBar();
|
||
this.bindSearchEvents();
|
||
this.bindToolbarEvents();
|
||
this.updateUndoRedoButtons();
|
||
}, 150);
|
||
},
|
||
render(forceFull = false) {
|
||
this.renderTabs();
|
||
const board = this.getActiveBoard();
|
||
const boardArea = document.getElementById("rbBoardArea");
|
||
if (!board) {
|
||
document.getElementById("rbEmptyState").style.display = "flex";
|
||
document.getElementById("rbBoardWrapper").style.display = "none";
|
||
if (boardArea) boardArea.style.background = "";
|
||
this._renderCache.boardId = null;
|
||
this._renderCache.columnsHash = "";
|
||
this.renderFilterBar();
|
||
return;
|
||
}
|
||
const columnsHash = board.columns
|
||
.map(
|
||
(c) =>
|
||
c.id +
|
||
":" +
|
||
c.cards.length +
|
||
":" +
|
||
c.cards.map((k) => k.id).join(","),
|
||
)
|
||
.join("|");
|
||
const needsFullRender =
|
||
forceFull ||
|
||
this._renderCache.boardId !== board.id ||
|
||
this._renderCache.columnsHash !== columnsHash;
|
||
if (needsFullRender) {
|
||
this._fullRenderBoard(board);
|
||
this._renderCache.boardId = board.id;
|
||
this._renderCache.columnsHash = columnsHash;
|
||
} else {
|
||
this._incrementalRender(board);
|
||
}
|
||
this.applyFilters();
|
||
this.initDragAndDrop();
|
||
this.updateFooterStats();
|
||
},
|
||
_fullRenderBoard(board) {
|
||
const emptyState = document.getElementById("rbEmptyState");
|
||
const wrapper = document.getElementById("rbBoardWrapper");
|
||
const boardArea = document.getElementById("rbBoardArea");
|
||
emptyState.style.display = "none";
|
||
wrapper.style.display = "flex";
|
||
boardArea.style.background = board.backgroundColor || "";
|
||
const fragment = document.createDocumentFragment();
|
||
board.columns.forEach((col) => {
|
||
const colEl = this._createColumnElement(col);
|
||
fragment.appendChild(colEl);
|
||
});
|
||
const addBtn = document.createElement("button");
|
||
addBtn.className = "add-column-btn";
|
||
addBtn.innerHTML = "➕ افزودن ستون جدید";
|
||
addBtn.onclick = () => this.openNewColumnModal();
|
||
fragment.appendChild(addBtn);
|
||
wrapper.innerHTML = "";
|
||
wrapper.appendChild(fragment);
|
||
},
|
||
_incrementalRender(board) {
|
||
const wrapper = document.getElementById("rbBoardWrapper");
|
||
if (!wrapper) return;
|
||
board.columns.forEach((col) => {
|
||
const colBody = wrapper.querySelector(
|
||
`[data-column-id="${col.id}"]`,
|
||
);
|
||
if (!colBody) return;
|
||
const countEl = colBody
|
||
.closest(".column")
|
||
.querySelector(".column-count");
|
||
if (countEl) countEl.textContent = col.cards.length;
|
||
const existingCards = new Map();
|
||
colBody.querySelectorAll(".card").forEach((el) => {
|
||
existingCards.set(el.dataset.cardId, el);
|
||
});
|
||
col.cards.forEach((card) => {
|
||
if (existingCards.has(card.id)) {
|
||
const el = existingCards.get(card.id);
|
||
this._updateCardElement(el, card);
|
||
existingCards.delete(card.id);
|
||
} else {
|
||
const newEl = this._createCardElement(card);
|
||
colBody.appendChild(newEl);
|
||
}
|
||
});
|
||
existingCards.forEach((el) => el.remove());
|
||
});
|
||
},
|
||
_createColumnElement(col) {
|
||
const div = document.createElement("div");
|
||
div.className = "column";
|
||
div.dataset.columnId = col.id;
|
||
if (col.backgroundColor) div.style.background = col.backgroundColor;
|
||
if (col.textColor) div.style.color = col.textColor;
|
||
div.innerHTML = this.renderColumn(col);
|
||
return div.firstElementChild;
|
||
},
|
||
_createCardElement(card) {
|
||
const div = document.createElement("div");
|
||
div.innerHTML = this.renderCard(card);
|
||
return div.firstElementChild;
|
||
},
|
||
_updateCardElement(el, card) {
|
||
const newHtml = this.renderCard(card);
|
||
if (el.innerHTML !== newHtml) el.innerHTML = newHtml;
|
||
},
|
||
_getFormattedDate(isoString) {
|
||
if (!isoString) return "";
|
||
if (this._dateCache.has(isoString))
|
||
return this._dateCache.get(isoString);
|
||
const formatted = new Date(isoString).toLocaleDateString("fa-IR");
|
||
this._dateCache.set(isoString, formatted);
|
||
if (this._dateCache.size > 500) {
|
||
const firstKey = this._dateCache.keys().next().value;
|
||
this._dateCache.delete(firstKey);
|
||
}
|
||
return formatted;
|
||
},
|
||
_countTagUsage(tagId) {
|
||
let count = 0;
|
||
const boards = projectData.appData.boards || [];
|
||
for (const board of boards) {
|
||
for (const col of board.columns) {
|
||
for (const card of col.cards) {
|
||
if ((card.tags || []).includes(tagId)) count++;
|
||
}
|
||
}
|
||
}
|
||
return count;
|
||
},
|
||
renderTabs() {
|
||
const tabsBar = document.getElementById("rbTabsBar");
|
||
if (!tabsBar) return;
|
||
const boards = projectData.appData.boards || [];
|
||
const hash =
|
||
boards.map((b) => b.id + ":" + b.title).join("|") +
|
||
"|" +
|
||
this.activeBoardId;
|
||
if (hash === this._tabsCache) return;
|
||
this._tabsCache = hash;
|
||
if (boards.length === 0) {
|
||
tabsBar.innerHTML =
|
||
'<div class="rb-empty-tabs">هیچ بردی ایجاد نشده است</div>';
|
||
return;
|
||
}
|
||
tabsBar.innerHTML =
|
||
boards
|
||
.map(
|
||
(board) =>
|
||
`<button class="rb-tab-item ${board.id === this.activeBoardId ? "active" : ""}" data-board-id="${board.id}"><span>📋</span><span>${this.escapeHtml(board.title)}</span><button class="rb-tab-close" data-delete-board="${board.id}" title="حذف برد">✕</button></button>`,
|
||
)
|
||
.join("") +
|
||
`<button class="rb-tab-add" onclick="ReleaseBoardsManager.openNewBoardModal()" title="ایجاد برد جدید">➕</button>`;
|
||
tabsBar.querySelectorAll(".rb-tab-item").forEach((tab) => {
|
||
tab.addEventListener("click", (e) => {
|
||
if (e.target.closest(".rb-tab-close")) return;
|
||
this.activeBoardId = tab.dataset.boardId;
|
||
this.render(true);
|
||
});
|
||
});
|
||
tabsBar.querySelectorAll(".rb-tab-close").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.deleteBoard(btn.dataset.deleteBoard);
|
||
});
|
||
});
|
||
},
|
||
renderColumn(col) {
|
||
const cardCount = col.cards.length;
|
||
const style = [];
|
||
if (col.backgroundColor)
|
||
style.push(`background:${col.backgroundColor};`);
|
||
if (col.textColor) style.push(`color:${col.textColor};`);
|
||
return `
|
||
<div class="column" data-column-id="${col.id}" style="${style.join(" ")}">
|
||
<div class="column-header" draggable="true">
|
||
<span class="drag-handle-hint" title="بکشید تا ستون جابجا شود">⠿</span>
|
||
<div class="column-title"><span>${this.escapeHtml(col.title)}</span><span class="column-count">${cardCount}</span></div>
|
||
<div class="column-actions">
|
||
<button class="column-action-btn" onclick="ReleaseBoardsManager.openEditColumnModal('${col.id}')" title="ویرایش">✏️</button>
|
||
<button class="column-action-btn" onclick="ReleaseBoardsManager.deleteColumn('${col.id}')" title="حذف">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<div class="column-body" data-column-id="${col.id}">
|
||
${col.cards.length === 0 ? `<div class="empty-state"><div style="font-size:30px;opacity:0.3;">📭</div><div style="font-size:11px;margin-top:6px;">کارتی وجود ندارد</div></div>` : col.cards.map((card) => this.renderCard(card)).join("")}
|
||
<div class="column-filter-empty">🔍 کارتی با فیلترهای فعلی یافت نشد</div>
|
||
</div>
|
||
<div class="column-footer">
|
||
<button class="add-card-btn" onclick="ReleaseBoardsManager.openNewCardModal('${col.id}')">➕ افزودن کارت</button>
|
||
</div>
|
||
</div>`;
|
||
},
|
||
renderCard(card) {
|
||
const tags = (card.tags || [])
|
||
.map((tid) => {
|
||
const tag = (projectData.appData.boardTags || []).find(
|
||
(t) => t.id === tid,
|
||
);
|
||
return tag
|
||
? `<span class="card-tag" style="background:${tag.color};">${this.escapeHtml(tag.name)}</span>`
|
||
: "";
|
||
})
|
||
.join("");
|
||
const checklistTotal = (card.checklist || []).length;
|
||
const checklistDone = (card.checklist || []).filter(
|
||
(i) => i.done,
|
||
).length;
|
||
const checklistHtml =
|
||
checklistTotal > 0
|
||
? `<div class="card-checklist-progress" title="${checklistDone}/${checklistTotal}"><div class="card-checklist-bar" style="width:${(checklistDone / checklistTotal) * 100}%;"></div></div>`
|
||
: "";
|
||
let dateClass = "",
|
||
dateText = "";
|
||
if (card.estimatedDate) {
|
||
dateText = this._getFormattedDate(card.estimatedDate);
|
||
const due = new Date(card.estimatedDate);
|
||
const diff = (due - Date.now()) / (1000 * 60 * 60 * 24);
|
||
if (diff < 0) dateClass = "overdue";
|
||
else if (diff <= 3) dateClass = "soon";
|
||
}
|
||
const assigneesHtml = (card.assignees || []).length > 0
|
||
? `<div class="card-assignees">${card.assignees.map((a) => `<span class="card-assignee-badge">👤 ${this.escapeHtml(a.name)}</span>`).join("")}</div>`
|
||
: "";
|
||
const depsHtml = (card.dependencies || []).length > 0
|
||
? `<div class="card-deps">${card.dependencies.map((depId) => {
|
||
const depCard = this.findCardById(depId);
|
||
return depCard ? `<span class="card-dep-badge">🔗 ${this.escapeHtml(depCard.title)}</span>` : "";
|
||
}).join("")}</div>`
|
||
: "";
|
||
const bgStyle = card.backgroundColor
|
||
? `background:${card.backgroundColor};`
|
||
: "";
|
||
return `
|
||
<div class="card" data-card-id="${card.id}" draggable="true" onclick="ReleaseBoardsManager.openEditCardModal('${card.id}')" style="${bgStyle}">
|
||
${tags ? `<div class="card-tags">${tags}</div>` : ""}
|
||
<div class="card-title">${this.escapeHtml(card.title)}</div>
|
||
${card.description ? `<div class="card-desc">${this.escapeHtml(card.description)}</div>` : ""}
|
||
${assigneesHtml}
|
||
${depsHtml}
|
||
<div class="card-meta">
|
||
${card.estimatedDate ? `<span class="card-meta-item ${dateClass}">📅 ${dateText}</span>` : ""}
|
||
${checklistTotal > 0 ? `<span class="card-meta-item">✅ ${checklistDone}/${checklistTotal}</span>` : ""}
|
||
</div>
|
||
${checklistHtml}
|
||
</div>`;
|
||
},
|
||
renderFilterBar() {
|
||
const container = document.getElementById("rbTagFilterContainer");
|
||
const clearBtn = document.getElementById("rbClearTagFilter");
|
||
if (!container) return;
|
||
const tags = projectData.appData.boardTags || [];
|
||
if (tags.length === 0) {
|
||
container.innerHTML =
|
||
'<span class="rb-filter-empty">تگی برای فیلتر وجود ندارد — از بخش «تگها» ایجاد کنید</span>';
|
||
if (clearBtn) {
|
||
clearBtn.disabled = true;
|
||
}
|
||
return;
|
||
}
|
||
container.innerHTML = tags
|
||
.map((tag) => {
|
||
const active = this.filters.tags.includes(tag.id);
|
||
return `<button class="rb-filter-chip ${active ? "active" : ""}" style="--chip-color:${tag.color}" data-tag-id="${tag.id}" title="${active ? "کلیک برای حذف از فیلتر" : "کلیک برای افزودن به فیلتر"}"><span class="chip-dot" style="background:${tag.color}"></span>${this.escapeHtml(tag.name)}</button>`;
|
||
})
|
||
.join("");
|
||
container.querySelectorAll(".rb-filter-chip").forEach((chip) => {
|
||
chip.addEventListener("click", () => {
|
||
const tid = chip.dataset.tagId;
|
||
const idx = this.filters.tags.indexOf(tid);
|
||
if (idx >= 0) {
|
||
this.filters.tags.splice(idx, 1);
|
||
showToast("🏷️ فیلتر تگ حذف شد", "info", 1500);
|
||
} else {
|
||
this.filters.tags.push(tid);
|
||
}
|
||
this.renderFilterBar();
|
||
this.applyFilters();
|
||
});
|
||
});
|
||
if (clearBtn) {
|
||
// ✅ اصلاح: دکمه حذف فیلتر همیشه نمایش داده شود
|
||
// فقط وقتی فیلتر فعال نیست، غیرفعال باشد
|
||
clearBtn.disabled = this.filters.tags.length === 0;
|
||
clearBtn.onclick = () => {
|
||
if (this.filters.tags.length === 0) return;
|
||
this.filters.tags = [];
|
||
this.renderFilterBar();
|
||
this.applyFilters();
|
||
showToast("🏷️ تمام فیلترهای تگ پاک شدند", "info");
|
||
};
|
||
}
|
||
},
|
||
applyFilters() {
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
const wrapper = document.getElementById("rbBoardWrapper");
|
||
if (!wrapper) return;
|
||
const searchQ = this.filters.search.trim().toLowerCase();
|
||
const activeTags = this.filters.tags;
|
||
const hasFilter = searchQ || activeTags.length > 0;
|
||
let visibleCount = 0,
|
||
totalCount = 0;
|
||
wrapper.querySelectorAll(".card").forEach((cardEl) => {
|
||
const cardId = cardEl.dataset.cardId;
|
||
let card = null;
|
||
for (const col of board.columns) {
|
||
const c = col.cards.find((x) => x.id === cardId);
|
||
if (c) {
|
||
card = c;
|
||
break;
|
||
}
|
||
}
|
||
if (!card) return;
|
||
totalCount++;
|
||
let matchSearch = true;
|
||
if (searchQ) {
|
||
const tagNames = (card.tags || [])
|
||
.map((tid) => {
|
||
const t = (projectData.appData.boardTags || []).find(
|
||
(x) => x.id === tid,
|
||
);
|
||
return t ? t.name : "";
|
||
})
|
||
.join(" ");
|
||
const hay = (
|
||
card.title +
|
||
" " +
|
||
(card.description || "") +
|
||
" " +
|
||
tagNames
|
||
).toLowerCase();
|
||
matchSearch = hay.includes(searchQ);
|
||
}
|
||
let matchTags = true;
|
||
if (activeTags.length > 0) {
|
||
matchTags = activeTags.some((tid) =>
|
||
(card.tags || []).includes(tid),
|
||
);
|
||
}
|
||
if (matchSearch && matchTags) {
|
||
cardEl.classList.remove("hidden-by-filter");
|
||
visibleCount++;
|
||
} else {
|
||
cardEl.classList.add("hidden-by-filter");
|
||
}
|
||
});
|
||
/* نمایش پیام خالی در ستونها هنگام فیلتر */
|
||
wrapper.querySelectorAll(".column").forEach((col) => {
|
||
const body = col.querySelector(".column-body");
|
||
if (!body) return;
|
||
const visibleCards = body.querySelectorAll(
|
||
".card:not(.hidden-by-filter)",
|
||
).length;
|
||
const totalCards = body.querySelectorAll(".card").length;
|
||
const emptyMsg = body.querySelector(".column-filter-empty");
|
||
if (emptyMsg) {
|
||
if (hasFilter && totalCards > 0 && visibleCards === 0)
|
||
emptyMsg.classList.add("visible");
|
||
else emptyMsg.classList.remove("visible");
|
||
}
|
||
});
|
||
/* بهروزرسانی وضعیت فیلتر */
|
||
const statusEl = document.getElementById("rbFilterStatus");
|
||
const countEl = document.getElementById("rbFilterCount");
|
||
const totalEl = document.getElementById("rbFilterTotal");
|
||
if (statusEl && countEl && totalEl) {
|
||
if (hasFilter) {
|
||
statusEl.classList.add("visible");
|
||
countEl.textContent = visibleCount.toLocaleString("fa-IR");
|
||
totalEl.textContent = totalCount.toLocaleString("fa-IR");
|
||
} else {
|
||
statusEl.classList.remove("visible");
|
||
}
|
||
}
|
||
},
|
||
bindSearchEvents() {
|
||
const inp = document.getElementById("rbCardSearchInput");
|
||
const clr = document.getElementById("rbCardSearchClear");
|
||
if (!inp || !clr) return;
|
||
inp.oninput = () => {
|
||
this.filters.search = inp.value;
|
||
clr.classList.toggle("visible", !!inp.value);
|
||
this.applyFilters();
|
||
};
|
||
inp.onkeydown = (e) => {
|
||
if (e.key === "Escape" && inp.value) {
|
||
inp.value = "";
|
||
this.filters.search = "";
|
||
clr.classList.remove("visible");
|
||
this.applyFilters();
|
||
inp.focus();
|
||
}
|
||
};
|
||
clr.onclick = () => {
|
||
inp.value = "";
|
||
this.filters.search = "";
|
||
clr.classList.remove("visible");
|
||
this.applyFilters();
|
||
inp.focus();
|
||
};
|
||
},
|
||
initDragAndDrop() {
|
||
const wrapper = document.getElementById("rbBoardWrapper");
|
||
if (!wrapper) return;
|
||
/* ── درگ کارتها ── */
|
||
wrapper.querySelectorAll(".card").forEach((card) => {
|
||
card.addEventListener("dragstart", (e) => {
|
||
e.stopPropagation();
|
||
card.classList.add("dragging");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", card.dataset.cardId);
|
||
this.dragType = "card";
|
||
this.dragCardId = card.dataset.cardId;
|
||
});
|
||
card.addEventListener("dragend", () => {
|
||
card.classList.remove("dragging");
|
||
this.clearDropIndicators();
|
||
this.dragType = null;
|
||
this.dragCardId = null;
|
||
});
|
||
});
|
||
/* ── درگ ستونها ── */
|
||
wrapper.querySelectorAll(".column").forEach((col) => {
|
||
const header = col.querySelector(".column-header");
|
||
if (header) {
|
||
header.addEventListener("dragstart", (e) => {
|
||
e.stopPropagation();
|
||
col.classList.add("dragging-column");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", "");
|
||
this.dragType = "column";
|
||
this.dragColumnId = col.dataset.columnId;
|
||
});
|
||
header.addEventListener("dragend", () => {
|
||
col.classList.remove("dragging-column");
|
||
this.clearDropIndicators();
|
||
this.dragType = null;
|
||
this.dragColumnId = null;
|
||
});
|
||
}
|
||
/* محل رها کردن کارت */
|
||
const body = col.querySelector(".column-body");
|
||
if (body) {
|
||
body.addEventListener("dragover", (e) => {
|
||
if (this.dragType === "card") {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
col.classList.add("drag-over");
|
||
}
|
||
});
|
||
body.addEventListener("dragleave", (e) => {
|
||
if (!col.contains(e.relatedTarget))
|
||
col.classList.remove("drag-over");
|
||
});
|
||
body.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
col.classList.remove("drag-over");
|
||
if (this.dragType === "card" && this.dragCardId) {
|
||
this.moveCard(this.dragCardId, col.dataset.columnId);
|
||
}
|
||
});
|
||
}
|
||
/* محل رها کردن ستون */
|
||
col.addEventListener("dragover", (e) => {
|
||
if (
|
||
this.dragType === "column" &&
|
||
this.dragColumnId !== col.dataset.columnId
|
||
) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
col.classList.add("drag-over-column");
|
||
}
|
||
});
|
||
col.addEventListener("dragleave", (e) => {
|
||
if (!col.contains(e.relatedTarget))
|
||
col.classList.remove("drag-over-column");
|
||
});
|
||
col.addEventListener("drop", (e) => {
|
||
if (this.dragType === "column" && this.dragColumnId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
col.classList.remove("drag-over-column");
|
||
this.moveColumn(this.dragColumnId, col.dataset.columnId);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
clearDropIndicators() {
|
||
document
|
||
.querySelectorAll(".drag-over")
|
||
.forEach((el) => el.classList.remove("drag-over"));
|
||
document
|
||
.querySelectorAll(".drag-over-column")
|
||
.forEach((el) => el.classList.remove("drag-over-column"));
|
||
},
|
||
moveCard(cardId, targetColumnId) {
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
let sourceCol = null,
|
||
card = null,
|
||
sourceIdx = -1;
|
||
for (const col of board.columns) {
|
||
const idx = col.cards.findIndex((c) => c.id === cardId);
|
||
if (idx >= 0) {
|
||
sourceCol = col;
|
||
card = col.cards[idx];
|
||
sourceIdx = idx;
|
||
break;
|
||
}
|
||
}
|
||
if (!card || !sourceCol) return;
|
||
if (sourceCol.id === targetColumnId) return;
|
||
const targetCol = board.columns.find((c) => c.id === targetColumnId);
|
||
if (!targetCol) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
sourceCol.cards.splice(sourceIdx, 1);
|
||
targetCol.cards.push(card);
|
||
if (!card.history) card.history = [];
|
||
card.history.push({
|
||
type: "move",
|
||
title: `جابجایی به "${targetCol.title}"`,
|
||
from: sourceCol.title,
|
||
to: targetCol.title,
|
||
date: new Date().toISOString(),
|
||
});
|
||
this.render(true);
|
||
showToast(
|
||
`📋 کارت به "${targetCol.title}" منتقل شد`,
|
||
"success",
|
||
2000,
|
||
);
|
||
},
|
||
moveColumn(sourceColId, targetColId) {
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
if (sourceColId === targetColId) return;
|
||
const sourceIdx = board.columns.findIndex(
|
||
(c) => c.id === sourceColId,
|
||
);
|
||
const targetIdx = board.columns.findIndex(
|
||
(c) => c.id === targetColId,
|
||
);
|
||
if (sourceIdx < 0 || targetIdx < 0) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
const [moved] = board.columns.splice(sourceIdx, 1);
|
||
board.columns.splice(targetIdx, 0, moved);
|
||
this.render(true);
|
||
showToast("📊 ستون جابجا شد", "success", 2000);
|
||
},
|
||
getActiveBoard() {
|
||
return (projectData.appData.boards || []).find(
|
||
(b) => b.id === this.activeBoardId,
|
||
);
|
||
},
|
||
openNewBoardModal() {
|
||
this.editing.boardId = null;
|
||
document.getElementById("rbBoardFormTitle").textContent =
|
||
"ایجاد برد جدید";
|
||
document.getElementById("rbBoardTitle").value = "";
|
||
document.getElementById("rbBoardDesc").value = "";
|
||
document.getElementById("rbBoardBgColor").value = "#e2e8f0";
|
||
const defaultGroup = document.getElementById("rbDefaultColumnsGroup");
|
||
if (defaultGroup) defaultGroup.style.display = "block";
|
||
const useDefault = document.getElementById("rbUseDefaultColumns");
|
||
if (useDefault) useDefault.checked = true;
|
||
const preview = document.getElementById("rbDefaultColumnsPreview");
|
||
if (preview) preview.classList.remove("hidden");
|
||
if (useDefault) {
|
||
useDefault.onchange = () => {
|
||
const preview = document.getElementById(
|
||
"rbDefaultColumnsPreview",
|
||
);
|
||
if (preview)
|
||
preview.classList.toggle("hidden", !useDefault.checked);
|
||
};
|
||
}
|
||
ModalManager.open("rbBoardFormModal");
|
||
},
|
||
openEditBoardModal() {
|
||
const board = this.getActiveBoard();
|
||
if (!board) {
|
||
showToast("بردی برای ویرایش وجود ندارد", "warning");
|
||
return;
|
||
}
|
||
this.editing.boardId = board.id;
|
||
document.getElementById("rbBoardFormTitle").textContent =
|
||
"ویرایش برد";
|
||
document.getElementById("rbBoardTitle").value = board.title;
|
||
document.getElementById("rbBoardDesc").value =
|
||
board.description || "";
|
||
document.getElementById("rbBoardBgColor").value =
|
||
board.backgroundColor || "#e2e8f0";
|
||
const defaultGroup = document.getElementById("rbDefaultColumnsGroup");
|
||
if (defaultGroup) defaultGroup.style.display = "none";
|
||
ModalManager.open("rbBoardFormModal");
|
||
},
|
||
saveBoard() {
|
||
const title = document.getElementById("rbBoardTitle").value.trim();
|
||
if (!title) {
|
||
showToast("عنوان الزامی است", "error");
|
||
return;
|
||
}
|
||
const desc = document.getElementById("rbBoardDesc").value.trim();
|
||
const bgColor = document.getElementById("rbBoardBgColor").value;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
if (!projectData.appData.boards) projectData.appData.boards = [];
|
||
if (this.editing.boardId) {
|
||
const board = projectData.appData.boards.find(
|
||
(b) => b.id === this.editing.boardId,
|
||
);
|
||
if (board) {
|
||
board.title = title;
|
||
board.description = desc;
|
||
board.backgroundColor = bgColor;
|
||
board.updatedAt = new Date().toISOString();
|
||
showToast("برد بروزرسانی شد", "success");
|
||
}
|
||
} else {
|
||
const useDefault = document.getElementById(
|
||
"rbUseDefaultColumns",
|
||
)?.checked;
|
||
const newBoard = {
|
||
id: `board-${Date.now()}`,
|
||
title,
|
||
description: desc,
|
||
backgroundColor: bgColor,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
columns: [],
|
||
};
|
||
if (useDefault) {
|
||
newBoard.columns = this.DEFAULT_COLUMNS.map((col, idx) => ({
|
||
id: `col-${Date.now()}-${idx}`,
|
||
title: col.title,
|
||
backgroundColor: col.backgroundColor,
|
||
textColor: col.textColor,
|
||
cards: [],
|
||
}));
|
||
}
|
||
projectData.appData.boards.push(newBoard);
|
||
this.activeBoardId = newBoard.id;
|
||
if (useDefault)
|
||
showToast(`برد "${title}" با ۵ ستون پیشفرض ایجاد شد`, "success");
|
||
else showToast(`برد خالی "${title}" ایجاد شد`, "success");
|
||
}
|
||
ModalManager.close("rbBoardFormModal");
|
||
requestAnimationFrame(() => {
|
||
this.render(true);
|
||
});
|
||
},
|
||
deleteBoard(boardId) {
|
||
const board = projectData.appData.boards.find(
|
||
(b) => b.id === boardId,
|
||
);
|
||
if (!board) return;
|
||
if (!confirm(`حذف برد "${board.title}" و تمام محتویات آن؟`)) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
projectData.appData.boards = projectData.appData.boards.filter(
|
||
(b) => b.id !== boardId,
|
||
);
|
||
if (this.activeBoardId === boardId)
|
||
this.activeBoardId = projectData.appData.boards[0]?.id || null;
|
||
showToast("برد حذف شد", "warning");
|
||
this.render(true);
|
||
},
|
||
openNewColumnModal() {
|
||
if (!this.getActiveBoard()) {
|
||
showToast("ابتدا یک برد ایجاد کنید", "warning");
|
||
return;
|
||
}
|
||
this.editing.columnId = null;
|
||
document.getElementById("rbColumnFormTitle").textContent =
|
||
"ایجاد ستون جدید";
|
||
document.getElementById("rbColumnTitle").value = "";
|
||
document.getElementById("rbColumnBgColor").value = "#edf2f7";
|
||
document.getElementById("rbColumnTextColor").value = "#1a202c";
|
||
ModalManager.open("rbColumnFormModal");
|
||
},
|
||
openEditColumnModal(colId) {
|
||
const board = this.getActiveBoard();
|
||
const col = board.columns.find((c) => c.id === colId);
|
||
if (!col) return;
|
||
this.editing.columnId = colId;
|
||
document.getElementById("rbColumnFormTitle").textContent =
|
||
"ویرایش ستون";
|
||
document.getElementById("rbColumnTitle").value = col.title;
|
||
document.getElementById("rbColumnBgColor").value =
|
||
col.backgroundColor || "#edf2f7";
|
||
document.getElementById("rbColumnTextColor").value =
|
||
col.textColor || "#1a202c";
|
||
ModalManager.open("rbColumnFormModal");
|
||
},
|
||
saveColumn() {
|
||
const title = document.getElementById("rbColumnTitle").value.trim();
|
||
if (!title) {
|
||
showToast("عنوان الزامی است", "error");
|
||
return;
|
||
}
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
const bgColor = document.getElementById("rbColumnBgColor").value;
|
||
const textColor = document.getElementById("rbColumnTextColor").value;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
if (this.editing.columnId) {
|
||
const col = board.columns.find(
|
||
(c) => c.id === this.editing.columnId,
|
||
);
|
||
if (col) {
|
||
col.title = title;
|
||
col.backgroundColor = bgColor;
|
||
col.textColor = textColor;
|
||
showToast("ستون بروزرسانی شد", "success");
|
||
}
|
||
} else {
|
||
board.columns.push({
|
||
id: `col-${Date.now()}`,
|
||
title,
|
||
backgroundColor: bgColor,
|
||
textColor: textColor,
|
||
cards: [],
|
||
});
|
||
showToast("ستون ایجاد شد", "success");
|
||
}
|
||
ModalManager.close("rbColumnFormModal");
|
||
requestAnimationFrame(() => {
|
||
this.render(true);
|
||
});
|
||
},
|
||
deleteColumn(colId) {
|
||
if (!confirm("حذف این ستون و تمام کارتهای آن؟")) return;
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
board.columns = board.columns.filter((c) => c.id !== colId);
|
||
showToast("ستون حذف شد", "warning");
|
||
this.render(true);
|
||
},
|
||
openNewCardModal(columnId) {
|
||
this.editing.cardId = null;
|
||
this.editing.columnForCard = columnId;
|
||
document.getElementById("rbCardFormTitle").textContent =
|
||
"ایجاد کارت جدید";
|
||
document.getElementById("rbCardTitle").value = "";
|
||
document.getElementById("rbCardDesc").value = "";
|
||
document.getElementById("rbCardDate").value = "";
|
||
document.getElementById("rbCardBgColor").value = "#ffffff";
|
||
document.getElementById("rbDeleteCardBtn").style.display = "none";
|
||
document.getElementById("rbCardHistoryGroup").style.display = "none";
|
||
this.tempData.cardTags = [];
|
||
this.tempData.checklist = [];
|
||
this.tempData.assignees = [];
|
||
this.tempData.dependencies = [];
|
||
this.renderCardTagsEditor();
|
||
this.renderChecklistEditor();
|
||
this.renderAssigneesEditor();
|
||
this.renderDependenciesEditor();
|
||
this.populateDependencySelect();
|
||
ModalManager.open("rbCardFormModal");
|
||
},
|
||
openEditCardModal(cardId) {
|
||
const board = this.getActiveBoard();
|
||
let card = null,
|
||
column = null;
|
||
for (const col of board.columns) {
|
||
const c = col.cards.find((x) => x.id === cardId);
|
||
if (c) {
|
||
card = c;
|
||
column = col;
|
||
break;
|
||
}
|
||
}
|
||
if (!card) return;
|
||
this.editing.cardId = cardId;
|
||
this.editing.columnForCard = column.id;
|
||
document.getElementById("rbCardFormTitle").textContent =
|
||
"ویرایش کارت";
|
||
document.getElementById("rbCardTitle").value = card.title;
|
||
document.getElementById("rbCardDesc").value = card.description || "";
|
||
if (card.estimatedDate) {
|
||
const d = new Date(card.estimatedDate);
|
||
const pad = (n) => n.toString().padStart(2, "0");
|
||
document.getElementById("rbCardDate").value =
|
||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
} else {
|
||
document.getElementById("rbCardDate").value = "";
|
||
}
|
||
document.getElementById("rbCardBgColor").value =
|
||
card.backgroundColor || "#ffffff";
|
||
document.getElementById("rbDeleteCardBtn").style.display =
|
||
"inline-flex";
|
||
document.getElementById("rbCardHistoryGroup").style.display = "block";
|
||
this.tempData.cardTags = [...(card.tags || [])];
|
||
this.tempData.checklist = (card.checklist || []).map((i) => ({
|
||
...i,
|
||
}));
|
||
this.tempData.assignees = [...(card.assignees || [])];
|
||
this.tempData.dependencies = [...(card.dependencies || [])];
|
||
this.renderCardTagsEditor();
|
||
this.renderChecklistEditor();
|
||
this.renderAssigneesEditor();
|
||
this.renderDependenciesEditor();
|
||
this.renderCardHistory();
|
||
this.populateDependencySelect();
|
||
ModalManager.open("rbCardFormModal");
|
||
},
|
||
renderCardTagsEditor() {
|
||
const container = document.getElementById("rbCardTagsContainer");
|
||
const tags = projectData.appData.boardTags || [];
|
||
if (tags.length === 0) {
|
||
container.innerHTML =
|
||
'<div style="font-size:12px;color:var(--text-muted);padding:10px;">تگی وجود ندارد</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = tags
|
||
.map((tag) => {
|
||
const selected = this.tempData.cardTags.includes(tag.id);
|
||
return `<div class="tag-chip ${selected ? "selected" : ""}" style="background:${tag.color};cursor:pointer;" onclick="ReleaseBoardsManager.toggleCardTag('${tag.id}')">${this.escapeHtml(tag.name)}</div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
toggleCardTag(tagId) {
|
||
const idx = this.tempData.cardTags.indexOf(tagId);
|
||
if (idx >= 0) this.tempData.cardTags.splice(idx, 1);
|
||
else this.tempData.cardTags.push(tagId);
|
||
this.renderCardTagsEditor();
|
||
},
|
||
renderChecklistEditor() {
|
||
const container = document.getElementById("rbChecklistContainer");
|
||
const list = this.tempData.checklist;
|
||
if (list.length === 0) {
|
||
container.innerHTML =
|
||
'<div style="font-size:12px;color:var(--text-muted);padding:10px;">آیتمی وجود ندارد</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = list
|
||
.map(
|
||
(item, idx) =>
|
||
`<div style="display:flex;align-items:center;gap:8px;padding:6px;background:var(--card-bg);border-radius:4px;margin-bottom:4px;"><input type="checkbox" ${item.done ? "checked" : ""} onchange="ReleaseBoardsManager.toggleChecklist(${idx})"><span style="flex:1;font-size:13px;${item.done ? "text-decoration:line-through;opacity:0.6;" : ""}">${this.escapeHtml(item.title)}</span><button class="btn btn-xs btn-danger" onclick="ReleaseBoardsManager.removeChecklist(${idx})">✕</button></div>`,
|
||
)
|
||
.join("");
|
||
},
|
||
addChecklistItem() {
|
||
const input = document.getElementById("rbNewChecklistItem");
|
||
const title = input.value.trim();
|
||
if (!title) return;
|
||
this.tempData.checklist.push({
|
||
id: `cl-${Date.now()}`,
|
||
title,
|
||
done: false,
|
||
});
|
||
input.value = "";
|
||
this.renderChecklistEditor();
|
||
},
|
||
toggleChecklist(idx) {
|
||
this.tempData.checklist[idx].done =
|
||
!this.tempData.checklist[idx].done;
|
||
this.renderChecklistEditor();
|
||
},
|
||
removeChecklist(idx) {
|
||
this.tempData.checklist.splice(idx, 1);
|
||
this.renderChecklistEditor();
|
||
},
|
||
saveCard() {
|
||
const title = document.getElementById("rbCardTitle").value.trim();
|
||
const desc = document.getElementById("rbCardDesc").value.trim();
|
||
if (!title) {
|
||
showToast("عنوان الزامی است", "error");
|
||
return;
|
||
}
|
||
if (!desc) {
|
||
showToast("توضیحات الزامی است", "error");
|
||
return;
|
||
}
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
const dateVal = document.getElementById("rbCardDate").value;
|
||
const bgColor = document.getElementById("rbCardBgColor").value;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
if (this.editing.cardId) {
|
||
for (const col of board.columns) {
|
||
const card = col.cards.find((c) => c.id === this.editing.cardId);
|
||
if (card) {
|
||
// ✅ ثبت تاریخچه ویرایش
|
||
const oldTitle = card.title;
|
||
card.title = title;
|
||
card.description = desc;
|
||
card.estimatedDate = dateVal
|
||
? new Date(dateVal).toISOString()
|
||
: null;
|
||
card.backgroundColor = bgColor;
|
||
card.tags = [...this.tempData.cardTags];
|
||
card.checklist = this.tempData.checklist.map((i) => ({ ...i }));
|
||
card.assignees = [...this.tempData.assignees];
|
||
card.dependencies = [...this.tempData.dependencies];
|
||
card.updatedAt = new Date().toISOString();
|
||
if (!card.history) card.history = [];
|
||
card.history.push({
|
||
type: "edit",
|
||
title: `ویرایش کارت`,
|
||
from: oldTitle !== title ? `"${oldTitle}" ← "${title}"` : null,
|
||
date: new Date().toISOString(),
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
showToast("کارت بروزرسانی شد", "success");
|
||
} else {
|
||
const column = board.columns.find(
|
||
(c) => c.id === this.editing.columnForCard,
|
||
);
|
||
if (column) {
|
||
column.cards.push({
|
||
id: `card-${Date.now()}`,
|
||
title,
|
||
description: desc,
|
||
estimatedDate: dateVal ? new Date(dateVal).toISOString() : null,
|
||
backgroundColor: bgColor,
|
||
tags: [...this.tempData.cardTags],
|
||
checklist: this.tempData.checklist.map((i) => ({ ...i })),
|
||
assignees: [...this.tempData.assignees],
|
||
dependencies: [...this.tempData.dependencies],
|
||
history: [
|
||
{
|
||
type: "create",
|
||
title: "ایجاد کارت",
|
||
from: "—",
|
||
to: column.title,
|
||
date: new Date().toISOString(),
|
||
},
|
||
],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
showToast("کارت ایجاد شد", "success");
|
||
}
|
||
}
|
||
ModalManager.close("rbCardFormModal");
|
||
requestAnimationFrame(() => {
|
||
this.render(true);
|
||
});
|
||
},
|
||
deleteCard() {
|
||
if (!confirm("حذف این کارت؟")) return;
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
HistoryManager.pushState();
|
||
AutosaveManager.scheduleSave();
|
||
for (const col of board.columns) {
|
||
const idx = col.cards.findIndex(
|
||
(c) => c.id === this.editing.cardId,
|
||
);
|
||
if (idx >= 0) {
|
||
col.cards.splice(idx, 1);
|
||
break;
|
||
}
|
||
}
|
||
ModalManager.close("rbCardFormModal");
|
||
showToast("کارت حذف شد", "warning");
|
||
this.render(true);
|
||
},
|
||
bindFormEvents() {
|
||
if (this._eventsBound) return;
|
||
this._eventsBound = true;
|
||
document.getElementById("rbSaveBoardBtn").onclick = () =>
|
||
this.saveBoard();
|
||
document.getElementById("rbSaveColumnBtn").onclick = () =>
|
||
this.saveColumn();
|
||
document.getElementById("rbSaveCardBtn").onclick = () =>
|
||
this.saveCard();
|
||
document.getElementById("rbDeleteCardBtn").onclick = () =>
|
||
this.deleteCard();
|
||
document.getElementById("rbNewAssigneeName").onkeydown = (e) => {
|
||
if (e.key === "Enter") this.addAssignee();
|
||
};
|
||
document.getElementById("rbNewChecklistItem").onkeydown = (e) => {
|
||
if (e.key === "Enter") this.addChecklistItem();
|
||
};
|
||
},
|
||
bindToolbarEvents() {
|
||
document.getElementById("rbEditBoard").onclick = () =>
|
||
this.openEditBoardModal();
|
||
document.getElementById("rbManageTags").onclick = () =>
|
||
this.openTagManager();
|
||
document.getElementById("rbUndoBtn").onclick = () =>
|
||
HistoryManager.undo();
|
||
document.getElementById("rbRedoBtn").onclick = () =>
|
||
HistoryManager.redo();
|
||
},
|
||
updateUndoRedoButtons() {
|
||
const undoLen = HistoryManager.undoStack.length;
|
||
const redoLen = HistoryManager.redoStack.length;
|
||
if (
|
||
this._lastUndoRedoState.undo === undoLen &&
|
||
this._lastUndoRedoState.redo === redoLen
|
||
)
|
||
return;
|
||
this._lastUndoRedoState = { undo: undoLen, redo: redoLen };
|
||
const undoBtn = document.getElementById("rbUndoBtn");
|
||
const redoBtn = document.getElementById("rbRedoBtn");
|
||
if (undoBtn) undoBtn.disabled = undoLen === 0;
|
||
if (redoBtn) redoBtn.disabled = redoLen === 0;
|
||
},
|
||
updateFooterStats() {
|
||
const boards = projectData.appData.boards || [];
|
||
const totalCards = boards.reduce(
|
||
(sum, b) => sum + b.columns.reduce((s, c) => s + c.cards.length, 0),
|
||
0,
|
||
);
|
||
document.getElementById("rbFooterStats").innerHTML =
|
||
`<span>📋 بردها: <span class="stat-highlight">${boards.length}</span></span><span>📑 ستونها: <span class="stat-highlight">${boards.reduce((s, b) => s + b.columns.length, 0)}</span></span><span>📝 کارتها: <span class="stat-highlight">${totalCards}</span></span>`;
|
||
},
|
||
openTagManager() {
|
||
ModalManager.open("rbTagManagerModal");
|
||
this.renderTagList();
|
||
this.bindTagEvents();
|
||
},
|
||
renderTagList() {
|
||
const container = document.getElementById("rbTagList");
|
||
const countEl = document.getElementById("rbTagCount");
|
||
if (!container) return;
|
||
const tags = projectData.appData.boardTags || [];
|
||
if (countEl)
|
||
countEl.textContent = tags.length.toLocaleString("fa-IR");
|
||
if (tags.length === 0) {
|
||
container.innerHTML = `<div class="rb-tag-empty"><div class="rb-tag-empty-icon">🏷️</div><div>هنوز تگی ایجاد نشده است</div><div style="font-size:11px;margin-top:4px;">از فرم بالا اولین تگ را بسازید</div></div>`;
|
||
return;
|
||
}
|
||
container.innerHTML = tags
|
||
.map((tag) => {
|
||
const usageCount = this._countTagUsage(tag.id);
|
||
return `<div class="rb-tag-item" data-tag-id="${tag.id}"><div class="rb-tag-color-dot" style="background:${tag.color};"></div><span class="rb-tag-name">${this.escapeHtml(tag.name)}</span><span class="rb-tag-usage">${usageCount.toLocaleString("fa-IR")} کارت</span><div class="rb-tag-actions"><button class="rb-tag-action-btn" onclick="ReleaseBoardsManager.openEditTagModal('${tag.id}')" title="ویرایش">✏️</button><button class="rb-tag-action-btn danger" onclick="ReleaseBoardsManager.deleteTag('${tag.id}')" title="حذف">🗑️</button></div></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
addTag() {
|
||
const nameInput = document.getElementById("rbNewTagName");
|
||
const colorInput = document.getElementById("rbNewTagColor");
|
||
const name = nameInput.value.trim();
|
||
const color = colorInput.value;
|
||
if (!name) {
|
||
showToast("نام تگ الزامی است", "error");
|
||
nameInput.focus();
|
||
return;
|
||
}
|
||
const tags = projectData.appData.boardTags || [];
|
||
if (tags.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
|
||
showToast("تگی با این نام وجود دارد", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
if (!projectData.appData.boardTags)
|
||
projectData.appData.boardTags = [];
|
||
projectData.appData.boardTags.push({
|
||
id: `tag-${Date.now()}`,
|
||
name,
|
||
color,
|
||
});
|
||
AutosaveManager.scheduleSave();
|
||
nameInput.value = "";
|
||
colorInput.value = "#4299e1";
|
||
this.renderTagList();
|
||
this.renderFilterBar();
|
||
showToast(`تگ "${name}" ایجاد شد`, "success");
|
||
},
|
||
openEditTagModal(tagId) {
|
||
const tags = projectData.appData.boardTags || [];
|
||
const tag = tags.find((t) => t.id === tagId);
|
||
if (!tag) return;
|
||
this.editing.tagId = tagId;
|
||
document.getElementById("rbEditTagName").value = tag.name;
|
||
document.getElementById("rbEditTagColor").value = tag.color;
|
||
const preview = document.getElementById("rbEditTagPreview");
|
||
preview.textContent = tag.name;
|
||
preview.style.background = tag.color;
|
||
const nameInput = document.getElementById("rbEditTagName");
|
||
const colorInput = document.getElementById("rbEditTagColor");
|
||
nameInput.oninput = () => {
|
||
preview.textContent = nameInput.value || "نمونه";
|
||
};
|
||
colorInput.oninput = () => {
|
||
preview.style.background = colorInput.value;
|
||
};
|
||
ModalManager.open("rbEditTagModal");
|
||
},
|
||
saveTag() {
|
||
const name = document.getElementById("rbEditTagName").value.trim();
|
||
const color = document.getElementById("rbEditTagColor").value;
|
||
if (!name) {
|
||
showToast("نام تگ الزامی است", "error");
|
||
return;
|
||
}
|
||
const tags = projectData.appData.boardTags || [];
|
||
if (
|
||
tags.some(
|
||
(t) =>
|
||
t.name.toLowerCase() === name.toLowerCase() &&
|
||
t.id !== this.editing.tagId,
|
||
)
|
||
) {
|
||
showToast("تگی با این نام وجود دارد", "warning");
|
||
return;
|
||
}
|
||
const tag = tags.find((t) => t.id === this.editing.tagId);
|
||
if (!tag) return;
|
||
HistoryManager.pushState();
|
||
tag.name = name;
|
||
tag.color = color;
|
||
AutosaveManager.scheduleSave();
|
||
ModalManager.close("rbEditTagModal");
|
||
this.renderTagList();
|
||
this.renderFilterBar();
|
||
this.render(true);
|
||
showToast(`تگ "${name}" بروزرسانی شد`, "success");
|
||
},
|
||
deleteTag(tagId) {
|
||
const tags = projectData.appData.boardTags || [];
|
||
const tag = tags.find((t) => t.id === tagId);
|
||
if (!tag) return;
|
||
const usageCount = this._countTagUsage(tagId);
|
||
const confirmMsg =
|
||
usageCount > 0
|
||
? `حذف تگ "${tag.name}"؟\nاین تگ در ${usageCount} کارت استفاده شده و از همه آنها حذف خواهد شد.`
|
||
: `حذف تگ "${tag.name}"؟`;
|
||
if (!confirm(confirmMsg)) return;
|
||
HistoryManager.pushState();
|
||
projectData.appData.boardTags = tags.filter((t) => t.id !== tagId);
|
||
const boards = projectData.appData.boards || [];
|
||
for (const board of boards) {
|
||
for (const col of board.columns) {
|
||
for (const card of col.cards) {
|
||
if (card.tags)
|
||
card.tags = card.tags.filter((tid) => tid !== tagId);
|
||
}
|
||
}
|
||
}
|
||
/* ✅ حذف تگ از فیلترهای فعال */
|
||
this.filters.tags = this.filters.tags.filter((tid) => tid !== tagId);
|
||
AutosaveManager.scheduleSave();
|
||
this.renderTagList();
|
||
this.renderFilterBar();
|
||
this.render(true);
|
||
showToast(`تگ "${tag.name}" حذف شد`, "warning");
|
||
},
|
||
bindTagEvents() {
|
||
document.getElementById("rbAddTagBtn").onclick = () => this.addTag();
|
||
document.getElementById("rbSaveTagBtn").onclick = () =>
|
||
this.saveTag();
|
||
document.getElementById("rbNewTagName").onkeydown = (e) => {
|
||
if (e.key === "Enter") this.addTag();
|
||
};
|
||
},
|
||
renderAssigneesEditor() {
|
||
const container = document.getElementById("rbAssigneesContainer");
|
||
if (!container) return;
|
||
const assignees = this.tempData.assignees || [];
|
||
if (assignees.length === 0) {
|
||
container.innerHTML =
|
||
'<span style="font-size:11px;color:var(--text-muted);padding:4px;">فردی مسئول نیست</span>';
|
||
return;
|
||
}
|
||
container.innerHTML = assignees
|
||
.map((a, idx) =>
|
||
`<span class="rb-item-chip assignee-chip">
|
||
👤 ${this.escapeHtml(a.name)}
|
||
<span class="chip-remove" onclick="event.stopPropagation();ReleaseBoardsManager.removeAssignee(${idx})">✕</span>
|
||
</span>`
|
||
)
|
||
.join("");
|
||
},
|
||
addAssignee() {
|
||
const input = document.getElementById("rbNewAssigneeName");
|
||
const name = input.value.trim();
|
||
if (!name) return;
|
||
if (this.tempData.assignees.some((a) => a.name === name)) {
|
||
showToast("این فرد قبلاً اضافه شده است", "warning");
|
||
return;
|
||
}
|
||
this.tempData.assignees.push({
|
||
id: `assignee-${Date.now()}`,
|
||
name,
|
||
});
|
||
input.value = "";
|
||
this.renderAssigneesEditor();
|
||
},
|
||
removeAssignee(idx) {
|
||
this.tempData.assignees.splice(idx, 1);
|
||
this.renderAssigneesEditor();
|
||
},
|
||
populateDependencySelect() {
|
||
const select = document.getElementById("rbDependencySelect");
|
||
if (!select) return;
|
||
const board = this.getActiveBoard();
|
||
if (!board) return;
|
||
const currentCardId = this.editing.cardId;
|
||
let options = '<option value="">انتخاب کارت برای وابستگی...</option>';
|
||
board.columns.forEach((col) => {
|
||
col.cards.forEach((card) => {
|
||
if (card.id !== currentCardId) {
|
||
options += `<option value="${card.id}">${this.escapeHtml(card.title)} (${this.escapeHtml(col.title)})</option>`;
|
||
}
|
||
});
|
||
});
|
||
select.innerHTML = options;
|
||
},
|
||
renderDependenciesEditor() {
|
||
const container = document.getElementById("rbDependenciesContainer");
|
||
if (!container) return;
|
||
const deps = this.tempData.dependencies || [];
|
||
if (deps.length === 0) {
|
||
container.innerHTML =
|
||
'<span style="font-size:11px;color:var(--text-muted);padding:4px;">وابستگیای وجود ندارد</span>';
|
||
return;
|
||
}
|
||
container.innerHTML = deps
|
||
.map((depId) => {
|
||
const depCard = this.findCardById(depId);
|
||
if (!depCard) return "";
|
||
return `<span class="rb-item-chip dependency-chip">
|
||
🔗 ${this.escapeHtml(depCard.title)}
|
||
<span class="chip-remove" onclick="event.stopPropagation();ReleaseBoardsManager.removeDependency('${depId}')">✕</span>
|
||
</span>`;
|
||
})
|
||
.join("");
|
||
},
|
||
addDependency() {
|
||
const select = document.getElementById("rbDependencySelect");
|
||
const depId = select.value;
|
||
if (!depId) {
|
||
showToast("کارتی انتخاب نشده است", "warning");
|
||
return;
|
||
}
|
||
if (this.tempData.dependencies.includes(depId)) {
|
||
showToast("این وابستگی قبلاً اضافه شده است", "warning");
|
||
return;
|
||
}
|
||
this.tempData.dependencies.push(depId);
|
||
select.value = "";
|
||
this.renderDependenciesEditor();
|
||
this.populateDependencySelect();
|
||
},
|
||
removeDependency(depId) {
|
||
this.tempData.dependencies = this.tempData.dependencies.filter(
|
||
(d) => d !== depId,
|
||
);
|
||
this.renderDependenciesEditor();
|
||
this.populateDependencySelect();
|
||
},
|
||
findCardById(cardId) {
|
||
const board = this.getActiveBoard();
|
||
if (!board) return null;
|
||
for (const col of board.columns) {
|
||
const card = col.cards.find((c) => c.id === cardId);
|
||
if (card) return card;
|
||
}
|
||
return null;
|
||
},
|
||
renderCardHistory() {
|
||
const container = document.getElementById("rbCardHistoryContainer");
|
||
if (!container) return;
|
||
const card = this.findCardById(this.editing.cardId);
|
||
if (!card || !card.history || card.history.length === 0) {
|
||
container.innerHTML =
|
||
'<span style="font-size:11px;color:var(--text-muted);padding:4px;">تاریخچهای وجود ندارد</span>';
|
||
return;
|
||
}
|
||
const icons = {
|
||
create: "🆕",
|
||
edit: "✏️",
|
||
move: "📋",
|
||
};
|
||
container.innerHTML = card.history
|
||
.map((h) => {
|
||
const icon = icons[h.type] || "📝";
|
||
const date = new Date(h.date);
|
||
const dateStr = date.toLocaleDateString("fa-IR") + " " + date.toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
|
||
let content = h.title;
|
||
if (h.from && h.from !== "—") {
|
||
content += ` — از "${h.from}"`;
|
||
if (h.to) content += ` به "${h.to}"`;
|
||
}
|
||
return `<div class="rb-history-item">
|
||
<span class="rb-history-icon">${icon}</span>
|
||
<span class="rb-history-content">${this.escapeHtml(content)}</span>
|
||
<span class="rb-history-date">${dateStr}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
escapeHtml(str) {
|
||
if (!str) return "";
|
||
return String(str).replace(
|
||
/[&<>"']/g,
|
||
(c) =>
|
||
({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
})[c],
|
||
);
|
||
},
|
||
};
|
||
/* ==================== ANALYSIS MANAGERS ==================== */
|
||
const DependencyAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("dependencyAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
getData() {
|
||
let totalDeps = 0,
|
||
totalProjects = 0,
|
||
brokenDeps = 0;
|
||
const allProjects = [];
|
||
const inDeps = {};
|
||
const typeCounts = {};
|
||
const matrix = {};
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
totalProjects++;
|
||
matrix[pr.id] = {};
|
||
(pr.dependencies || []).forEach((depId) => {
|
||
totalDeps++;
|
||
if (!ProductManager.findProjectById(depId)) brokenDeps++;
|
||
else {
|
||
inDeps[depId] = (inDeps[depId] || 0) + 1;
|
||
matrix[pr.id][depId] = true;
|
||
}
|
||
typeCounts[pr.type] = (typeCounts[pr.type] || 0) + 1;
|
||
});
|
||
});
|
||
});
|
||
const bottlenecks = Object.entries(inDeps)
|
||
.filter(([k, v]) => v >= 2)
|
||
.map(([id, count]) => ({
|
||
project: ProductManager.findProjectById(id),
|
||
count,
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
const isolated = allProjects.filter(
|
||
(pr) =>
|
||
(pr.dependencies || []).length === 0 && !(inDeps[pr.id] > 0),
|
||
);
|
||
return {
|
||
totalDeps,
|
||
totalProjects,
|
||
brokenDeps,
|
||
bottlenecks,
|
||
isolated,
|
||
typeCounts,
|
||
matrix,
|
||
allProjects,
|
||
inDeps,
|
||
};
|
||
},
|
||
render() {
|
||
const c = document.getElementById("dependencyAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.getData();
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.totalDeps}</div><div class="dashboard-kpi-label">کل وابستگیها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.totalProjects}</div><div class="dashboard-kpi-label">پروژهها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.bottlenecks.length}</div><div class="dashboard-kpi-label">گلوگاه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.brokenDeps}</div><div class="dashboard-kpi-label">شکسته</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.isolated.length}</div><div class="dashboard-kpi-label">ایزوله</div></div>`;
|
||
h += "</div>";
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 گلوگاهها (بیشترین وابستگی ورودی)</div><div class="dashboard-chart-body" id="depHubChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژههای وابسته</div><div class="dashboard-chart-body" id="depTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">🟥 ماتریس وابستگی</div><div class="dashboard-chart-body" id="depMatrixChart" style="overflow:auto;max-height:300px"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسکها</div><div class="dashboard-chart-body" id="depRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
c.innerHTML = h;
|
||
this.renderHubChart("depHubChart", d.bottlenecks);
|
||
this.renderTypeDonut("depTypeChart", d.typeCounts);
|
||
this.renderMatrix("depMatrixChart", d.allProjects, d.matrix);
|
||
this.renderRiskList("depRiskList", d);
|
||
setTimeout(() => {
|
||
document.querySelectorAll(".dashboard-chart-body").forEach((body) => {
|
||
const svg = body.querySelector("svg");
|
||
if (svg) {
|
||
ChartZoomManager.enable(body, svg);
|
||
}
|
||
});
|
||
}, 200);
|
||
document.getElementById("depAnalysisFooterStats").innerHTML =
|
||
`<span>🔗 کل: <span class="stat-highlight">${d.totalDeps}</span></span><span>⚠️ گلوگاه: <span class="stat-highlight">${d.bottlenecks.length}</span></span><span>🔴 شکسته: <span class="stat-highlight">${d.brokenDeps}</span></span><span>🔵 ایزوله: <span class="stat-highlight">${d.isolated.length}</span></span>`;
|
||
},
|
||
renderHubChart(cid, bottlenecks) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!bottlenecks || bottlenecks.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>گلوگاهی نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = Math.max(120, bottlenecks.length * 40 + 40);
|
||
const m = { top: 10, right: 35, bottom: 10, left: 130 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(bottlenecks, (d) => d.count) || 1])
|
||
.range([0, iw]);
|
||
const y = d3
|
||
.scaleBand()
|
||
.domain(bottlenecks.map((d) => (d.project ? d.project.title : "")))
|
||
.range([0, ih])
|
||
.padding(0.3);
|
||
g.selectAll("rect")
|
||
.data(bottlenecks)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (d) => y(d.project ? d.project.title : ""))
|
||
.attr("width", (d) => x(d.count))
|
||
.attr("height", y.bandwidth())
|
||
.attr("fill", (d) => (d.count >= 3 ? "#e53e3e" : "#dd6b20"))
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(
|
||
e,
|
||
`${d.project ? d.project.title : ""}: ${d.count} وابستگی ورودی`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, d) {
|
||
d3.select(this).attr(
|
||
"fill",
|
||
d.count >= 3 ? "#e53e3e" : "#dd6b20",
|
||
);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".label")
|
||
.data(bottlenecks)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr(
|
||
"y",
|
||
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
|
||
)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "600")
|
||
.text((d) => {
|
||
const t = d.project ? d.project.title : "";
|
||
return t.length > 18 ? t.substring(0, 18) + "..." : t;
|
||
});
|
||
g.selectAll(".value")
|
||
.data(bottlenecks)
|
||
.join("text")
|
||
.attr("x", (d) => x(d.count) + 5)
|
||
.attr(
|
||
"y",
|
||
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
|
||
)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.count);
|
||
},
|
||
renderTypeDonut(cid, typeCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const entries = Object.entries(typeCounts);
|
||
if (entries.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>🥧</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((d) => d[1])
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const total = entries.reduce((s, d) => s + d[1], 0);
|
||
const colors = {
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
};
|
||
g.selectAll("path")
|
||
.data(pie(entries))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (d) => colors[d.data[0]] || "#718096")
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(d));
|
||
const ti = VALID_PROJECT_TYPES.find((t) => t.value === d.data[0]);
|
||
showD3Tooltip(
|
||
e,
|
||
`${ti ? ti.label : d.data[0]}: ${d.data[1]} (${Math.round((d.data[1] / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(total);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("وابستگی");
|
||
},
|
||
renderMatrix(cid, allProjects, matrix) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!allProjects || allProjects.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>🟥</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const cs = 28,
|
||
lw = 140;
|
||
const w = lw + allProjects.length * cs + 20;
|
||
const hgt = 40 + allProjects.length * cs + 20;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg.append("g");
|
||
allProjects.forEach((pr, i) => {
|
||
g.append("text")
|
||
.attr("x", lw - 8)
|
||
.attr("y", 40 + i * cs + cs / 2)
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.text(
|
||
pr.title.length > 20
|
||
? pr.title.substring(0, 20) + "..."
|
||
: pr.title,
|
||
);
|
||
});
|
||
allProjects.forEach((pr, i) => {
|
||
g.append("text")
|
||
.attr("x", lw + i * cs + cs / 2)
|
||
.attr("y", 30)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", `rotate(-45, ${lw + i * cs + cs / 2}, 30)`)
|
||
.text(
|
||
pr.title.length > 12
|
||
? pr.title.substring(0, 12) + "..."
|
||
: pr.title,
|
||
);
|
||
});
|
||
allProjects.forEach((row, i) => {
|
||
allProjects.forEach((col, j) => {
|
||
const hasDep = matrix[row.id] && matrix[row.id][col.id];
|
||
g.append("rect")
|
||
.attr("x", lw + j * cs)
|
||
.attr("y", 40 + i * cs)
|
||
.attr("width", cs - 2)
|
||
.attr("height", cs - 2)
|
||
.attr("rx", 3)
|
||
.attr("fill", hasDep ? "#e53e3e" : "var(--border-light)")
|
||
.attr("opacity", hasDep ? 0.9 : 0.5)
|
||
.style("cursor", hasDep ? "pointer" : "default")
|
||
.on(
|
||
"mouseover",
|
||
hasDep
|
||
? (e) => showD3Tooltip(e, `${row.title} → ${col.title}`)
|
||
: null,
|
||
)
|
||
.on("mousemove", hasDep ? (e) => showD3Tooltip(e, "") : null)
|
||
.on("mouseout", hasDep ? () => hideD3Tooltip() : null);
|
||
});
|
||
});
|
||
},
|
||
renderRiskList(cid, d) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
let h = "";
|
||
if (d.brokenDeps > 0)
|
||
h += `<div style="padding:10px 12px;background:rgba(229,62,62,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #e53e3e"><div style="font-weight:700;font-size:12px">🔴 وابستگیهای شکسته</div><div style="font-size:11px;color:var(--text-muted)">${d.brokenDeps} وابستگی به پروژههای ناموجود</div></div>`;
|
||
d.bottlenecks.forEach((b) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(221,107,32,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #dd6b20"><div style="font-weight:700;font-size:12px">⚠️ گلوگاه: ${b.project ? b.project.title : ""}</div><div style="font-size:11px;color:var(--text-muted)">${b.count} پروژه به این پروژه وابستهاند - ریسک بالا در صورت تغییر</div></div>`;
|
||
});
|
||
if (d.isolated.length > 0)
|
||
h += `<div style="padding:10px 12px;background:rgba(49,130,206,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #3182ce"><div style="font-weight:700;font-size:12px">🔵 پروژههای ایزوله</div><div style="font-size:11px;color:var(--text-muted)">${d.isolated.map((p) => p.title).join("، ")}</div></div>`;
|
||
if (!h)
|
||
h =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
const DeploymentAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("deploymentAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
getData() {
|
||
let total = 0,
|
||
pwd = 0,
|
||
pwod = 0,
|
||
withNotes = 0,
|
||
withoutNotes = 0;
|
||
const edc = {},
|
||
ppc = [],
|
||
riskList = [];
|
||
const now = new Date();
|
||
const thirtyDaysAgo = new Date(
|
||
now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
projectData.appData.products.forEach((prod) => {
|
||
let prodDeployCount = 0;
|
||
prod.projects.forEach((pr) => {
|
||
const active = ProductManager.getActiveDeployments(pr);
|
||
if (active.length > 0) pwd++;
|
||
else {
|
||
pwod++;
|
||
riskList.push({
|
||
type: "no-deploy",
|
||
severity: "high",
|
||
title: "بدون استقرار",
|
||
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
|
||
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
|
||
});
|
||
}
|
||
active.forEach((d) => {
|
||
total++;
|
||
prodDeployCount++;
|
||
if (d.releaseNotes) withNotes++;
|
||
else {
|
||
withoutNotes++;
|
||
riskList.push({
|
||
type: "no-notes",
|
||
severity: "medium",
|
||
title: "بدون ریلیز نوت",
|
||
desc: `${pr.title} v${d.version}`,
|
||
rec: "ریلیز نوت را تکمیل کنید",
|
||
});
|
||
}
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
edc[envName] = (edc[envName] || 0) + 1;
|
||
if (
|
||
d.deploymentDate &&
|
||
new Date(d.deploymentDate) < thirtyDaysAgo
|
||
) {
|
||
const daysOld = Math.floor(
|
||
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
|
||
);
|
||
riskList.push({
|
||
type: "stale",
|
||
severity: daysOld > 90 ? "high" : "medium",
|
||
title: "استقرار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "بررسی و بهروزرسانی نسخه",
|
||
});
|
||
}
|
||
});
|
||
});
|
||
ppc.push({ title: prod.title, count: prodDeployCount });
|
||
});
|
||
const coverage =
|
||
pwd + pwod > 0 ? Math.round((pwd / (pwd + pwod)) * 100) : 0;
|
||
return {
|
||
total,
|
||
pwd,
|
||
pwod,
|
||
withNotes,
|
||
withoutNotes,
|
||
edc,
|
||
ppc,
|
||
riskList,
|
||
coverage,
|
||
};
|
||
},
|
||
render() {
|
||
const c = document.getElementById("deploymentAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.getData();
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل استقرارها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">✅</div><div class="dashboard-kpi-value">${d.pwd}</div><div class="dashboard-kpi-label">با استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">❌</div><div class="dashboard-kpi-value">${d.pwod}</div><div class="dashboard-kpi-label">بدون استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value">${d.coverage}%</div><div class="dashboard-kpi-label">پوشش استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📝</div><div class="dashboard-kpi-value">${d.withNotes}</div><div class="dashboard-kpi-label">با ریلیز نوت</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.withoutNotes}</div><div class="dashboard-kpi-label">بدون ریلیز نوت</div></div>`;
|
||
h += "</div>";
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="deployEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژهها</div><div class="dashboard-chart-body" id="deployStatusChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 استقرارها به تفکیک محصول</div><div class="dashboard-chart-body" id="deployProductChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">⚠️ لیست ریسکها</div><div class="dashboard-chart-body" id="deployRiskList" style="align-items:flex-start;overflow-y:auto;max-height:280px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
c.innerHTML = h;
|
||
this.renderEnvBarChart("deployEnvChart", d.edc);
|
||
this.renderStatusDonut(
|
||
"deployStatusChart",
|
||
d.pwd,
|
||
d.pwod,
|
||
d.coverage,
|
||
);
|
||
this.renderProductBarChart("deployProductChart", d.ppc);
|
||
this.renderRiskList("deployRiskList", d.riskList);
|
||
setTimeout(() => {
|
||
document.querySelectorAll(".dashboard-chart-body").forEach((body) => {
|
||
const svg = body.querySelector("svg");
|
||
if (svg) {
|
||
ChartZoomManager.enable(body, svg);
|
||
}
|
||
});
|
||
}, 200);
|
||
document.getElementById("deployAnalysisFooterStats").innerHTML =
|
||
`<span>🚀 کل: <span class="stat-highlight">${d.total}</span></span><span>📈 پوشش: <span class="stat-highlight">${d.coverage}%</span></span><span>⚠️ ریسکها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
|
||
},
|
||
renderEnvBarChart(cid, envCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
|
||
const entries = Object.entries(envCounts).filter((d) => d[1] > 0);
|
||
|
||
if (entries.length === 0) {
|
||
c.innerHTML = '<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const w = 320, hgt = 220, m = { top: 20, right: 15, bottom: 45, left: 30 };
|
||
const iw = w - m.left - m.right, ih = hgt - m.top - m.bottom;
|
||
|
||
const svg = d3.select(c).append("svg").attr("width", w).attr("height", hgt);
|
||
const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`);
|
||
|
||
const xScale = d3.scaleBand().domain(entries.map((d) => d[0])).range([0, iw]).padding(0.3);
|
||
const yScale = d3.scaleLinear().domain([0, d3.max(entries, (d) => d[1]) || 1]).nice().range([ih, 0]);
|
||
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(xScale).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-20)")
|
||
.attr("text-anchor", "end");
|
||
|
||
g.append("g")
|
||
.call(d3.axisLeft(yScale).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
|
||
// ✅ اصلاح: استفاده از d به جای x
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", (d) => xScale(d[0]))
|
||
.attr("y", (d) => yScale(d[1]))
|
||
.attr("width", xScale.bandwidth())
|
||
.attr("height", (d) => ih - yScale(d[1]))
|
||
.attr("fill", "#38a169")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d[0]}: ${d[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#38a169");
|
||
hideD3Tooltip();
|
||
});
|
||
|
||
g.selectAll(".bar-label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (d) => xScale(d[0]) + xScale.bandwidth() / 2)
|
||
.attr("y", (d) => yScale(d[1]) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d[1]);
|
||
},
|
||
renderStatusDonut(cid, pwd, pwod, coverage) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const total = pwd + pwod;
|
||
if (total === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((d) => d.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const data = [
|
||
{ label: "با استقرار", value: pwd, color: "#38a169" },
|
||
{ label: "بدون استقرار", value: pwod, color: "#e53e3e" },
|
||
].filter((d) => d.value > 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (d) => d.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(d));
|
||
showD3Tooltip(
|
||
e,
|
||
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr(
|
||
"fill",
|
||
coverage >= 70
|
||
? "#38a169"
|
||
: coverage >= 40
|
||
? "#dd6b20"
|
||
: "#e53e3e",
|
||
)
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(`${coverage}%`);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("پوشش استقرار");
|
||
},
|
||
renderProductBarChart(cid, ppc) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
|
||
const data = ppc.map((p) => ({
|
||
label: p.title.length > 15 ? p.title.substring(0, 15) + "..." : p.title,
|
||
value: p.count,
|
||
}));
|
||
|
||
if (data.length === 0) {
|
||
c.innerHTML = '<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const bh = 28, gap = 6;
|
||
const w = 320, hgt = data.length * (bh + gap) + 30;
|
||
const m = { top: 10, right: 35, bottom: 10, left: 130 };
|
||
const iw = w - m.left - m.right, ih = hgt - m.top - m.bottom;
|
||
|
||
const svg = d3.select(c).append("svg").attr("width", w).attr("height", hgt);
|
||
const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`);
|
||
|
||
const xScale = d3.scaleLinear().domain([0, d3.max(data, (d) => d.value) || 1]).range([0, iw]);
|
||
const yScale = d3.scaleBand().domain(data.map((d) => d.label)).range([0, ih]).padding(0.2);
|
||
|
||
// ✅ این متد از قبل درست بود (از d استفاده میکرد)
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (d) => yScale(d.label))
|
||
.attr("width", (d) => xScale(d.value))
|
||
.attr("height", yScale.bandwidth())
|
||
.attr("fill", "#3182ce")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d.label}: ${d.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#3182ce");
|
||
hideD3Tooltip();
|
||
});
|
||
|
||
g.selectAll(".label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr("y", (d) => yScale(d.label) + yScale.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-weight", "600")
|
||
.text((d) => d.label);
|
||
|
||
g.selectAll(".value")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (d) => xScale(d.value) + 5)
|
||
.attr("y", (d) => yScale(d.label) + yScale.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.value);
|
||
},
|
||
renderRiskList(cid, riskList) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
if (riskList.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
return;
|
||
}
|
||
const colors = {
|
||
critical: "#e53e3e",
|
||
high: "#dd6b20",
|
||
medium: "#d69e2e",
|
||
};
|
||
let h = "";
|
||
riskList.forEach((r) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
|
||
h += `<div style="font-weight:700;font-size:12px">${r.title}</div>`;
|
||
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
|
||
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
|
||
h += "</div>";
|
||
});
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
const VersionAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("versionAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
getData() {
|
||
let total = 0;
|
||
const uniqueVersions = new Set();
|
||
const majorCounts = {},
|
||
envCounts = {},
|
||
envMatrix = {},
|
||
riskList = [];
|
||
const now = new Date();
|
||
const thirtyDaysAgo = new Date(
|
||
now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const ninetyDaysAgo = new Date(
|
||
now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const prereleaseRegex = /-(rc|beta|dev|alpha)/i;
|
||
projectData.appData.environments.forEach((e) => {
|
||
envCounts[e.name] = 0;
|
||
});
|
||
projectData.appData.products.forEach((prod) => {
|
||
(prod.projects || []).forEach((pr) => {
|
||
const active = ProductManager.getActiveDeployments(pr);
|
||
envMatrix[pr.title] = {};
|
||
active.forEach((d) => {
|
||
total++;
|
||
uniqueVersions.add(d.version);
|
||
const major = parseInt(d.version.split(".")[0]) || 0;
|
||
const majorKey = `v${major}.x`;
|
||
majorCounts[majorKey] = (majorCounts[majorKey] || 0) + 1;
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
envCounts[envName] = (envCounts[envName] || 0) + 1;
|
||
envMatrix[pr.title][d.environmentId] = d.version;
|
||
if (
|
||
d.deploymentDate &&
|
||
new Date(d.deploymentDate) < ninetyDaysAgo
|
||
) {
|
||
const daysOld = Math.floor(
|
||
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
|
||
);
|
||
riskList.push({
|
||
type: "stale-critical",
|
||
severity: "high",
|
||
icon: "⏰",
|
||
title: "نسخه بسیار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "فوراً بررسی و بهروزرسانی کنید",
|
||
});
|
||
} else if (
|
||
d.deploymentDate &&
|
||
new Date(d.deploymentDate) < thirtyDaysAgo
|
||
) {
|
||
const daysOld = Math.floor(
|
||
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
|
||
);
|
||
riskList.push({
|
||
type: "stale-medium",
|
||
severity: "medium",
|
||
icon: "⏰",
|
||
title: "نسخه قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "برنامهریزی بهروزرسانی",
|
||
});
|
||
}
|
||
if (!d.releaseNotes)
|
||
riskList.push({
|
||
type: "no-notes",
|
||
severity: "medium",
|
||
icon: "📝",
|
||
title: "بدون ریلیز نوت",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "ریلیز نوت را تکمیل کنید",
|
||
});
|
||
if (prereleaseRegex.test(d.version)) {
|
||
if (d.environmentId === "env-prod-001")
|
||
riskList.push({
|
||
type: "prerelease-prod",
|
||
severity: "high",
|
||
icon: "🧪",
|
||
title: "نسخه پیشنویس در Production",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "نسخه پایدار را در Production مستقر کنید",
|
||
});
|
||
else
|
||
riskList.push({
|
||
type: "prerelease-other",
|
||
severity: "low",
|
||
icon: "🧪",
|
||
title: "نسخه پیشنویس",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "پس از تست، نسخه پایدار را منتشر کنید",
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
let avgMajor = 0,
|
||
avgMinor = 0,
|
||
avgPatch = 0;
|
||
if (uniqueVersions.size > 0) {
|
||
let sumMajor = 0,
|
||
sumMinor = 0,
|
||
sumPatch = 0;
|
||
uniqueVersions.forEach((v) => {
|
||
const parts = v.split(".");
|
||
sumMajor += parseInt(parts[0]) || 0;
|
||
sumMinor += parseInt(parts[1]) || 0;
|
||
sumPatch += parseInt(parts[2]) || 0;
|
||
});
|
||
avgMajor = Math.round(sumMajor / uniqueVersions.size);
|
||
avgMinor = Math.round(sumMinor / uniqueVersions.size);
|
||
avgPatch = Math.round(sumPatch / uniqueVersions.size);
|
||
}
|
||
const prereleaseCount = [...uniqueVersions].filter((v) =>
|
||
prereleaseRegex.test(v),
|
||
).length;
|
||
return {
|
||
total,
|
||
uniqueVersions: uniqueVersions.size,
|
||
avgVersion: `${avgMajor}.${avgMinor}.${avgPatch}`,
|
||
prereleaseCount,
|
||
majorCounts,
|
||
envCounts,
|
||
envMatrix,
|
||
riskList,
|
||
};
|
||
},
|
||
render() {
|
||
const c = document.getElementById("versionAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.getData();
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔢</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل نسخهها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.uniqueVersions}</div><div class="dashboard-kpi-label">نسخههای یکتا</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value" style="font-size:20px">${d.avgVersion}</div><div class="dashboard-kpi-label">میانگین نسخه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🧪</div><div class="dashboard-kpi-value">${d.prereleaseCount}</div><div class="dashboard-kpi-label">پیشنویس</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.riskList.length}</div><div class="dashboard-kpi-label">ریسکها</div></div>`;
|
||
h += "</div>";
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نسخهها بر اساس Major</div><div class="dashboard-chart-body" id="verMajorChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 نسخهها به تفکیک محیط</div><div class="dashboard-chart-body" id="verEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">📋 مقایسه نسخهها بین محیطها</div><div class="dashboard-chart-body" id="verEnvMatrix" style="overflow:auto;max-height:300px;width:100%"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسکها</div><div class="dashboard-chart-body" id="verRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
c.innerHTML = h;
|
||
this.renderMajorBarChart("verMajorChart", d.majorCounts);
|
||
this.renderEnvBarChart("verEnvChart", d.envCounts);
|
||
this.renderEnvMatrix("verEnvMatrix", d.envMatrix);
|
||
this.renderRiskList("verRiskList", d.riskList);
|
||
setTimeout(() => {
|
||
document.querySelectorAll(".dashboard-chart-body").forEach((body) => {
|
||
const svg = body.querySelector("svg");
|
||
if (svg) {
|
||
ChartZoomManager.enable(body, svg);
|
||
}
|
||
});
|
||
}, 200);
|
||
document.getElementById("verAnalysisFooterStats").innerHTML =
|
||
`<span>🔢 کل: <span class="stat-highlight">${d.total}</span></span><span>📦 یکتا: <span class="stat-highlight">${d.uniqueVersions}</span></span><span>📈 میانگین: <span class="stat-highlight">${d.avgVersion}</span></span><span>⚠️ ریسکها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
|
||
},
|
||
renderMajorBarChart(cid, majorCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
|
||
const entries = Object.entries(majorCounts).sort((a, b) => a[0].localeCompare(b[0]));
|
||
|
||
if (entries.length === 0) {
|
||
c.innerHTML = '<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const w = 320, hgt = 220, m = { top: 20, right: 15, bottom: 40, left: 30 };
|
||
const iw = w - m.left - m.right, ih = hgt - m.top - m.bottom;
|
||
|
||
const svg = d3.select(c).append("svg").attr("width", w).attr("height", hgt);
|
||
const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`);
|
||
|
||
const xScale = d3.scaleBand().domain(entries.map((d) => d[0])).range([0, iw]).padding(0.3);
|
||
const yScale = d3.scaleLinear().domain([0, d3.max(entries, (d) => d[1]) || 1]).nice().range([ih, 0]);
|
||
|
||
const colors = ["#4299e1", "#48bb78", "#ecc94b", "#ed64a6", "#9f7aea", "#ed8936"];
|
||
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(xScale).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-family", "Vazirmatn");
|
||
|
||
g.append("g")
|
||
.call(d3.axisLeft(yScale).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
|
||
// ✅ اصلاح: استفاده از d به جای x
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", (d) => xScale(d[0]))
|
||
.attr("y", (d) => yScale(d[1]))
|
||
.attr("width", xScale.bandwidth())
|
||
.attr("height", (d) => ih - yScale(d[1]))
|
||
.attr("fill", (d, i) => colors[i % colors.length])
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d[0]}: ${d[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, d) {
|
||
d3.select(this).attr("fill", colors[entries.indexOf(d) % colors.length]);
|
||
hideD3Tooltip();
|
||
});
|
||
|
||
g.selectAll(".bar-label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (d) => xScale(d[0]) + xScale.bandwidth() / 2)
|
||
.attr("y", (d) => yScale(d[1]) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d[1]);
|
||
},
|
||
renderEnvBarChart(cid, envCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const entries = Object.entries(envCounts).filter((x) => x[1] > 0);
|
||
if (entries.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = 220,
|
||
m = { top: 20, right: 15, bottom: 45, left: 30 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(entries.map((x) => x[0]))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(entries, (x) => x[1]) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-20)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", (x) => x(x[0]))
|
||
.attr("y", (x) => y(x[1]))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (x) => ih - y(x[1]))
|
||
.attr("fill", "#38a169")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, x) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${x[0]}: ${x[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#38a169");
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (x) => x(x[0]) + x.bandwidth() / 2)
|
||
.attr("y", (x) => y(x[1]) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((x) => x[1]);
|
||
},
|
||
renderEnvMatrix(cid, envMatrix) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const projects = Object.keys(envMatrix);
|
||
if (projects.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📋</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const envs = projectData.appData.environments || [];
|
||
let html =
|
||
'<table style="width:100%;border-collapse:collapse;font-size:11px">';
|
||
html += '<thead><tr style="background:var(--border-light)">';
|
||
html +=
|
||
'<th style="padding:8px;text-align:right;border-bottom:2px solid var(--border);font-weight:700">پروژه</th>';
|
||
envs.forEach((env) => {
|
||
html += `<th style="padding:8px;text-align:center;border-bottom:2px solid var(--border);font-weight:700"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${env.backgroundColor};margin-left:4px"></span>${env.name}</th>`;
|
||
});
|
||
html += "</tr></thead><tbody>";
|
||
projects.forEach((prTitle) => {
|
||
html += `<tr style="border-bottom:1px solid var(--border-light)">`;
|
||
html += `<td style="padding:6px 8px;font-weight:600">${prTitle}</td>`;
|
||
envs.forEach((env) => {
|
||
const version = envMatrix[prTitle][env.id];
|
||
if (version)
|
||
html += `<td style="padding:6px 8px;text-align:center"><span style="font-family:monospace;font-weight:700;color:var(--primary)">v${version}</span></td>`;
|
||
else
|
||
html +=
|
||
'<td style="padding:6px 8px;text-align:center;color:var(--text-muted)">—</td>';
|
||
});
|
||
html += "</tr>";
|
||
});
|
||
html += "</tbody></table>";
|
||
c.innerHTML = html;
|
||
},
|
||
renderRiskList(cid, riskList) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
if (riskList.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
return;
|
||
}
|
||
const severityOrder = { high: 0, medium: 1, low: 2 };
|
||
const sorted = [...riskList].sort(
|
||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
|
||
);
|
||
const colors = { high: "#dd6b20", medium: "#d69e2e", low: "#3182ce" };
|
||
let h = "";
|
||
sorted.forEach((r) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
|
||
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
|
||
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
|
||
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
|
||
h += "</div>";
|
||
});
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
const RiskAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("riskAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
analyzeRisks() {
|
||
const risks = [];
|
||
const now = new Date();
|
||
const thirtyDaysAgo = new Date(
|
||
now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const ninetyDaysAgo = new Date(
|
||
now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const envs = projectData.appData.environments || [];
|
||
const envIdsWithDeployment = new Set();
|
||
projectData.appData.products.forEach((prod) => {
|
||
if (!prod.metadata?.owner)
|
||
risks.push({
|
||
type: "no-owner",
|
||
severity: "medium",
|
||
icon: "👤",
|
||
title: "بدون مالک",
|
||
desc: `محصول "${prod.title}" مالک مشخصی ندارد`,
|
||
rec: "مالک محصول را مشخص کنید",
|
||
});
|
||
(prod.projects || []).forEach((pr) => {
|
||
(pr.dependencies || []).forEach((depId) => {
|
||
if (!ProductManager.findProjectById(depId))
|
||
risks.push({
|
||
type: "broken",
|
||
severity: "critical",
|
||
icon: "🔴",
|
||
title: "وابستگی شکسته",
|
||
desc: `${pr.title} به پروژه ناموجود (${depId}) وابسته است`,
|
||
rec: "وابستگی را حذف یا پروژه مقصد را ایجاد کنید",
|
||
});
|
||
});
|
||
const active = ProductManager.getActiveDeployments(pr);
|
||
if (active.length === 0)
|
||
risks.push({
|
||
type: "no-deploy",
|
||
severity: "high",
|
||
icon: "❌",
|
||
title: "بدون استقرار",
|
||
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
|
||
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
|
||
});
|
||
active.forEach((d) => {
|
||
envIdsWithDeployment.add(d.environmentId);
|
||
if (d.requiredDeployments && d.requiredDeployments.length > 0) {
|
||
d.requiredDeployments.forEach((rd) => {
|
||
if (!ProductManager.findProjectById(rd.projectId))
|
||
risks.push({
|
||
type: "broken-req",
|
||
severity: "critical",
|
||
icon: "🔴",
|
||
title: "نیازمندی شکسته",
|
||
desc: `${pr.title} به پروژه ناموجود در نیازمندیها ارجاع دارد`,
|
||
rec: "نیازمندی را بررسی و اصلاح کنید",
|
||
});
|
||
});
|
||
}
|
||
if (!d.releaseNotes) {
|
||
const envName = ProductManager.getEnvironmentName(
|
||
d.environmentId,
|
||
);
|
||
risks.push({
|
||
type: "no-notes",
|
||
severity: "medium",
|
||
icon: "📝",
|
||
title: "بدون ریلیز نوت",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "ریلیز نوت را تکمیل کنید",
|
||
});
|
||
}
|
||
if (d.deploymentDate) {
|
||
const depDate = new Date(d.deploymentDate);
|
||
const daysOld = Math.floor(
|
||
(now - depDate) / (1000 * 60 * 60 * 24),
|
||
);
|
||
const envName = ProductManager.getEnvironmentName(
|
||
d.environmentId,
|
||
);
|
||
if (depDate < ninetyDaysAgo)
|
||
risks.push({
|
||
type: "stale-critical",
|
||
severity: "high",
|
||
icon: "⏰",
|
||
title: "استقرار بسیار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "فوراً بررسی و بهروزرسانی کنید",
|
||
});
|
||
else if (depDate < thirtyDaysAgo)
|
||
risks.push({
|
||
type: "stale-medium",
|
||
severity: "medium",
|
||
icon: "⏰",
|
||
title: "استقرار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "برنامهریزی بهروزرسانی",
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
envs.forEach((env) => {
|
||
if (!envIdsWithDeployment.has(env.id))
|
||
risks.push({
|
||
type: "empty-environment",
|
||
severity: "low",
|
||
icon: "🌍",
|
||
title: "محیط خالی",
|
||
desc: `محیط "${env.name}" هیچ استقراری ندارد`,
|
||
rec: "در صورت عدم نیاز، محیط را حذف کنید",
|
||
});
|
||
});
|
||
const crit = risks.filter((r) => r.severity === "critical").length;
|
||
const high = risks.filter((r) => r.severity === "high").length;
|
||
const med = risks.filter((r) => r.severity === "medium").length;
|
||
const low = risks.filter((r) => r.severity === "low").length;
|
||
let healthScore = 100 - crit * 15 - high * 10 - med * 5 - low * 2;
|
||
healthScore = Math.max(0, healthScore);
|
||
return {
|
||
risks,
|
||
crit,
|
||
high,
|
||
med,
|
||
low,
|
||
healthScore,
|
||
total: risks.length,
|
||
};
|
||
},
|
||
render() {
|
||
const c = document.getElementById("riskAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.analyzeRisks();
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🎯</div><div class="dashboard-kpi-value">${d.healthScore}%</div><div class="dashboard-kpi-label">امتیاز سلامت</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.crit}</div><div class="dashboard-kpi-label">بحرانی</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟠</div><div class="dashboard-kpi-value">${d.high}</div><div class="dashboard-kpi-label">بالا</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟡</div><div class="dashboard-kpi-value">${d.med}</div><div class="dashboard-kpi-label">متوسط</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.low}</div><div class="dashboard-kpi-label">کم</div></div>`;
|
||
h += "</div>";
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🍩 توزیع شدت ریسکها</div><div class="dashboard-chart-body" id="riskSeverityChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نوع ریسک</div><div class="dashboard-chart-body" id="riskTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسکها و پیشنهادات</div><div class="dashboard-chart-body" id="riskDetailList" style="align-items:flex-start;overflow-y:auto;max-height:350px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
c.innerHTML = h;
|
||
this.renderSeverityDonut("riskSeverityChart", d);
|
||
this.renderTypeBarChart("riskTypeChart", d.risks);
|
||
this.renderRiskList("riskDetailList", d.risks);
|
||
setTimeout(() => {
|
||
document.querySelectorAll(".dashboard-chart-body").forEach((body) => {
|
||
const svg = body.querySelector("svg");
|
||
if (svg) {
|
||
ChartZoomManager.enable(body, svg);
|
||
}
|
||
});
|
||
}, 200);
|
||
document.getElementById("riskAnalysisFooterStats").innerHTML =
|
||
`<span>🎯 سلامت: <span class="stat-highlight">${d.healthScore}%</span></span><span>⚠️ ریسکها: <span class="stat-highlight">${d.total}</span></span><span>🔴 بحرانی: <span class="stat-highlight">${d.crit}</span></span><span>🟠 بالا: <span class="stat-highlight">${d.high}</span></span>`;
|
||
},
|
||
renderSeverityDonut(cid, d) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const data = [
|
||
{ label: "بحرانی", value: d.crit, color: "#e53e3e" },
|
||
{ label: "بالا", value: d.high, color: "#dd6b20" },
|
||
{ label: "متوسط", value: d.med, color: "#d69e2e" },
|
||
{ label: "کم", value: d.low, color: "#3182ce" },
|
||
].filter((x) => x.value > 0);
|
||
if (data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((x) => x.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const total = data.reduce((s, x) => s + x.value, 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (x) => x.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, x) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(x));
|
||
showD3Tooltip(
|
||
e,
|
||
`${x.data.label}: ${x.data.value} (${Math.round((x.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr(
|
||
"fill",
|
||
d.healthScore >= 70
|
||
? "#38a169"
|
||
: d.healthScore >= 40
|
||
? "#dd6b20"
|
||
: "#e53e3e",
|
||
)
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(`${d.healthScore}%`);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("امتیاز سلامت");
|
||
},
|
||
renderTypeBarChart(cid, risks) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
|
||
const typeLabels = {
|
||
broken: "وابستگی شکسته",
|
||
"broken-req": "نیازمندی شکسته",
|
||
"no-deploy": "بدون استقرار",
|
||
"stale-critical": "استقرار بسیار قدیمی",
|
||
"stale-medium": "استقرار قدیمی",
|
||
"no-notes": "بدون ریلیز نوت",
|
||
"no-owner": "بدون مالک",
|
||
"empty-environment": "محیط خالی",
|
||
};
|
||
|
||
const typeColors = {
|
||
broken: "#e53e3e",
|
||
"broken-req": "#e53e3e",
|
||
"no-deploy": "#dd6b20",
|
||
"stale-critical": "#dd6b20",
|
||
"stale-medium": "#d69e2e",
|
||
"no-notes": "#d69e2e",
|
||
"no-owner": "#d69e2e",
|
||
"empty-environment": "#3182ce",
|
||
};
|
||
|
||
const typeCounts = {};
|
||
risks.forEach((r) => {
|
||
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
|
||
});
|
||
|
||
const entries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
|
||
|
||
if (entries.length === 0) {
|
||
c.innerHTML = '<div class="dashboard-empty-chart"><span>✅</span><div>ریسکی نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const bh = 28, gap = 6;
|
||
const w = 320, hgt = entries.length * (bh + gap) + 30;
|
||
const m = { top: 10, right: 35, bottom: 10, left: 130 };
|
||
const iw = w - m.left - m.right, ih = hgt - m.top - m.bottom;
|
||
|
||
const svg = d3.select(c).append("svg").attr("width", w).attr("height", hgt);
|
||
const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`);
|
||
|
||
const xScale = d3.scaleLinear().domain([0, d3.max(entries, (d) => d[1]) || 1]).range([0, iw]);
|
||
const yScale = d3.scaleBand().domain(entries.map((d) => d[0])).range([0, ih]).padding(0.2);
|
||
|
||
// ✅ اصلاح: استفاده از d به جای x در arrow functions
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (d) => yScale(d[0]))
|
||
.attr("width", (d) => xScale(d[1]))
|
||
.attr("height", yScale.bandwidth())
|
||
.attr("fill", (d) => typeColors[d[0]] || "#718096")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${typeLabels[d[0]] || d[0]}: ${d[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, d) {
|
||
d3.select(this).attr("fill", typeColors[d[0]] || "#718096");
|
||
hideD3Tooltip();
|
||
});
|
||
|
||
g.selectAll(".label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr("y", (d) => yScale(d[0]) + yScale.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-weight", "600")
|
||
.text((d) => typeLabels[d[0]] || d[0]);
|
||
|
||
g.selectAll(".value")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (d) => xScale(d[1]) + 5)
|
||
.attr("y", (d) => yScale(d[0]) + yScale.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d[1]);
|
||
},
|
||
renderRiskList(cid, risks) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
if (risks.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
return;
|
||
}
|
||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||
const sorted = [...risks].sort(
|
||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
|
||
);
|
||
const colors = {
|
||
critical: "#e53e3e",
|
||
high: "#dd6b20",
|
||
medium: "#d69e2e",
|
||
low: "#3182ce",
|
||
};
|
||
let h = "";
|
||
sorted.forEach((r) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
|
||
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
|
||
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
|
||
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
|
||
h += "</div>";
|
||
});
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
/* ==================== EVENT BINDINGS ==================== */
|
||
document
|
||
.getElementById("projectDetailDeleteBtn")
|
||
.addEventListener("click", () => {
|
||
if (ProductManager.currentDetailProductId)
|
||
ProductManager.removeProduct(ProductManager.currentDetailProductId);
|
||
});
|
||
document
|
||
.getElementById("saveProductEditBtn")
|
||
.addEventListener("click", () => ProductManager.saveProductEdit());
|
||
document
|
||
.getElementById("saveNewProjectBtn")
|
||
.addEventListener("click", () => ProductManager.saveNewProject());
|
||
document
|
||
.getElementById("saveProjEditBtn")
|
||
.addEventListener("click", () => ProductManager.saveProjectEdit());
|
||
document
|
||
.getElementById("saveDeploymentBtn")
|
||
.addEventListener("click", () => ProductManager.saveDeployment());
|
||
document
|
||
.getElementById("saveEnvEditBtn")
|
||
.addEventListener("click", () => EnvironmentManager.saveEdit());
|
||
document
|
||
.getElementById("saveNewProductBtn")
|
||
.addEventListener("click", () => ProductManager.createNewProduct());
|
||
document
|
||
.getElementById("saveNewEnvBtn")
|
||
.addEventListener("click", () => EnvironmentManager.saveNewEnv());
|
||
document
|
||
.getElementById("newEnvBgColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("newEnvBgColorText").textContent =
|
||
e.target.value;
|
||
});
|
||
document
|
||
.getElementById("newEnvTextColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("newEnvTextColorText").textContent =
|
||
e.target.value;
|
||
});
|
||
document
|
||
.getElementById("editEnvBgColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("editEnvBgColorText").textContent =
|
||
e.target.value;
|
||
EnvironmentManager.updateEditPreview();
|
||
});
|
||
document
|
||
.getElementById("editEnvTextColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("editEnvTextColorText").textContent =
|
||
e.target.value;
|
||
EnvironmentManager.updateEditPreview();
|
||
});
|
||
document
|
||
.getElementById("galaxyResetView")
|
||
.addEventListener("click", () => {
|
||
if (GalaxyChartManager.svg && GalaxyChartManager.zoomBehavior)
|
||
GalaxyChartManager.svg
|
||
.transition()
|
||
.duration(500)
|
||
.call(GalaxyChartManager.zoomBehavior.transform, d3.zoomIdentity);
|
||
});
|
||
document
|
||
.getElementById("galaxyInfoClose")
|
||
.addEventListener("click", () => {
|
||
document
|
||
.getElementById("galaxyInfoPanel")
|
||
.classList.remove("visible");
|
||
});
|
||
document
|
||
.getElementById("galaxyCollapseAll")
|
||
.addEventListener("click", () => {
|
||
GalaxyChartManager.collapseAllProducts();
|
||
});
|
||
document
|
||
.getElementById("galaxyExpandAll")
|
||
.addEventListener("click", () => {
|
||
GalaxyChartManager.expandAllProducts();
|
||
});
|
||
document
|
||
.getElementById("galaxyExportPNG")
|
||
.addEventListener("click", () => {
|
||
GalaxyChartManager.exportPNG();
|
||
showToast("📷 خروجی ذخیره شد", "success");
|
||
});
|
||
function handleToolSelect(tool, group) {
|
||
const ah = ActionRegistry[tool.action] || ActionRegistry.defaultAction;
|
||
ah(tool, group);
|
||
}
|
||
const themeToggle = document.getElementById("themeToggle"),
|
||
themeIcon = document.getElementById("themeIcon"),
|
||
htmlEl = document.documentElement;
|
||
const savedTheme = localStorage.getItem("theme") || "light";
|
||
htmlEl.setAttribute("data-theme", savedTheme);
|
||
if (themeIcon)
|
||
themeIcon.textContent = savedTheme === "dark" ? "☀️" : "🌙";
|
||
if (themeToggle) {
|
||
themeToggle.addEventListener("click", () => {
|
||
const c = htmlEl.getAttribute("data-theme");
|
||
const n = c === "dark" ? "light" : "dark";
|
||
htmlEl.setAttribute("data-theme", n);
|
||
localStorage.setItem("theme", n);
|
||
if (themeIcon) themeIcon.textContent = n === "dark" ? "☀️" : "🌙";
|
||
});
|
||
}
|
||
const sidebarToggle = document.getElementById("sidebarToggle"),
|
||
sidebar = document.getElementById("sidebar"),
|
||
sidebarIcon = document.getElementById("sidebarIcon");
|
||
function updateSidebarIcon() {
|
||
if (!sidebarIcon) return;
|
||
sidebarIcon.textContent = sidebar.classList.contains("collapsed")
|
||
? "☰"
|
||
: "✕";
|
||
}
|
||
if (sidebarToggle) {
|
||
sidebarToggle.addEventListener("click", () => {
|
||
sidebar.classList.toggle("collapsed");
|
||
updateSidebarIcon();
|
||
});
|
||
}
|
||
updateSidebarIcon();
|
||
const sidebarBody = document.getElementById("sidebarBody");
|
||
function renderToolGroups() {
|
||
sidebarBody.innerHTML = "";
|
||
projectData.toolGroups.forEach((group) => {
|
||
const ge = document.createElement("div");
|
||
ge.className = "sidebar-group";
|
||
const he = document.createElement("div");
|
||
he.className = "sidebar-group-header";
|
||
he.innerHTML = `<div class="group-icon"><span>${group.icon || ""}</span><span>${group.title}</span></div><span class="group-arrow">▼</span>`;
|
||
const be = document.createElement("div");
|
||
be.className = "sidebar-group-body";
|
||
group.tools.forEach((tool) => {
|
||
const btn = document.createElement("button");
|
||
btn.className = "sidebar-btn";
|
||
btn.title = tool.description;
|
||
btn.innerHTML = `<span style="width:20px;text-align:center">${tool.icon || "🔧"}</span><span style="flex:1">${tool.title}</span>`;
|
||
btn.addEventListener("click", () => handleToolSelect(tool, group));
|
||
be.appendChild(btn);
|
||
});
|
||
he.addEventListener("click", () => ge.classList.toggle("collapsed"));
|
||
ge.appendChild(he);
|
||
ge.appendChild(be);
|
||
sidebarBody.appendChild(ge);
|
||
});
|
||
}
|
||
function openSettingsModal() {
|
||
document.getElementById("settingProjectTitle").value =
|
||
projectData.projectTitle;
|
||
document.getElementById("settingVersion").value = projectData.version;
|
||
document.getElementById("settingDescription").value =
|
||
projectData.description;
|
||
document.getElementById("settingDeveloper").value =
|
||
projectData.developer;
|
||
document.getElementById("settingCompany").value = projectData.company;
|
||
document.getElementById("settingWebhookUrl").value =
|
||
projectData.webhook.url;
|
||
document.getElementById("settingWebhookClientId").value =
|
||
projectData.webhook.clientId;
|
||
document.getElementById("settingWebhookEnabled").checked =
|
||
projectData.webhook.enabled;
|
||
WebhookManager.updateStatusUI();
|
||
ModalManager.open("settingsModal");
|
||
}
|
||
[
|
||
"settingWebhookUrl",
|
||
"settingWebhookClientId",
|
||
"settingWebhookEnabled",
|
||
].forEach((id) => {
|
||
document.getElementById(id).addEventListener("input", () => {
|
||
WebhookManager.setConfig({
|
||
url: document.getElementById("settingWebhookUrl").value,
|
||
clientId: document.getElementById("settingWebhookClientId").value,
|
||
enabled: document.getElementById("settingWebhookEnabled").checked,
|
||
});
|
||
});
|
||
});
|
||
document
|
||
.getElementById("saveSettingsBtn")
|
||
.addEventListener("click", () => {
|
||
WebhookManager.setConfig({
|
||
url: document.getElementById("settingWebhookUrl").value.trim(),
|
||
clientId: document
|
||
.getElementById("settingWebhookClientId")
|
||
.value.trim(),
|
||
enabled: document.getElementById("settingWebhookEnabled").checked,
|
||
});
|
||
ModalManager.close("settingsModal");
|
||
showToast("تنظیمات ذخیره شد", "success");
|
||
});
|
||
function updateProjectUI() {
|
||
document.title = projectData.projectTitle;
|
||
document.getElementById("headerTitle").textContent =
|
||
projectData.projectTitle;
|
||
document.getElementById("headerSubtitle").textContent =
|
||
projectData.description;
|
||
document.getElementById("appFooter").innerHTML =
|
||
`<div><span class="footer-brand">طراح و توسعهدهنده:</span> ${projectData.developer} <span style="margin:0 10px">|</span><span class="footer-brand">شرکت:</span> ${projectData.company}</div><div><span class="footer-brand">نسخه:</span> ${projectData.version}</div>`;
|
||
}
|
||
/* ==================== INIT ==================== */
|
||
ModalManager.init();
|
||
renderToolGroups();
|
||
updateProjectUI();
|
||
WebhookManager.updateStatusUI();
|
||
ToolbarManager.init();
|
||
SectionCollapseManager.init();
|
||
BoardRenderer.render();
|
||
HistoryManager.init();
|
||
AutosaveManager.checkRecovery();
|
||
window.addEventListener("beforeunload", () => {
|
||
AutosaveManager.save();
|
||
});
|
||
setTimeout(
|
||
() =>
|
||
showToast(
|
||
`${projectData.projectTitle} (نسخه ${projectData.version}) آماده است`,
|
||
"success",
|
||
),
|
||
500,
|
||
);
|
||
</script>
|
||
</body>
|
||
|
||
</html> |