Files
xReleaseAsistant/xReleaseAssistant.html
T
2026-09-19 10:29:13 +03:30

9789 lines
337 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
min-height: 220px;
}
.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;
}
.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: var(--primary-dark);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
pointer-events: none;
z-index: 3000;
display: none;
font-family: "Vazirmatn", sans-serif;
}
.d3-tooltip.visible {
display: block;
}
.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>
<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="newProjectLanguage" />
</div>
<div class="form-group">
<label class="form-label">آدرس مخزن</label><input type="text" class="form-input" id="newProjectRepo"
dir="ltr" />
</div>
</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="editProjLanguage" />
</div>
<div class="form-group">
<label class="form-label">آدرس مخزن</label><input type="text" class="form-input" id="editProjRepo"
dir="ltr" />
</div>
</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>
<!-- 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">
<div class="galaxy-stats" id="galaxyStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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">
<div class="dashboard-footer-stats" id="dashboardFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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">
<div class="dashboard-footer-stats" id="depAnalysisFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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">
<div class="dashboard-footer-stats" id="deployAnalysisFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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">
<div class="dashboard-footer-stats" id="verAnalysisFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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">
<div class="dashboard-footer-stats" id="riskAnalysisFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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>
<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;
tt.textContent = text;
tt.classList.add("visible");
tt.style.left = e.clientX + 12 + "px";
tt.style.top = e.clientY + 12 + "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: "" },
init() {
this.populateEnvironmentFilter();
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("");
},
bindEvents() {
const si = document.getElementById("toolbarSearch"),
ft = document.getElementById("filterType"),
fe = document.getElementById("filterEnvironment");
if (si)
si.addEventListener("input", () => {
this.applyFilters();
refreshUI();
});
if (ft)
ft.addEventListener("change", () => {
this.applyFilters();
refreshUI();
});
if (fe)
fe.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 || "",
};
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) 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();
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();
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,
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");
},
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 deploys = this.getActiveDeployments(pr);
const emptyEnvs = this.getEnvironmentsWithoutDeployment(pr);
const canAdd = emptyEnvs.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">${pr.metadata?.framework ? `<div class="detail-info-item"><span class="detail-info-label">فریم‌ورک</span><span class="detail-info-value">${pr.metadata.framework}</span></div>` : ""}${pr.metadata?.language ? `<div class="detail-info-item"><span class="detail-info-label">زبان</span><span class="detail-info-value">${pr.metadata.language}</span></div>` : ""}${deps.length > 0
? `<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>`
: ""
}</div>`;
html += `<div class="deployment-section"><div class="deployment-header"><span class="deployment-header-title">🚀 استقرارهای فعال (${deploys.length})</span>${canAdd ? `<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"><button class="btn btn-ghost btn-xs" onclick="ProductManager.openEditDeploymentModal('${prod.id}','${pr.id}','${d.id}')">✏️</button><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')">🗑️</button></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>`;
}
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();
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();
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("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(), customFields: {} },
dependencies: [],
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("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 lg = document.getElementById("editProjLanguage").value.trim();
if (lg) pr.metadata.language = lg;
else delete pr.metadata.language;
pr.metadata.updatedAt = new Date().toISOString();
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 emptyEnvs = this.getEnvironmentsWithoutDeployment(pr);
if (emptyEnvs.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 = projectData.appData.environments.filter((e) =>
emptyEnvs.includes(e.id),
);
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 || [];
if (deps.length === 0) {
section.innerHTML = "";
return;
}
let h =
'<div class="form-group"><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}">${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>";
});
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 = [];
document.querySelectorAll(".req-deployment-select").forEach((sel) => {
const depProjectId = sel.dataset.depProject;
const depDeploymentId = sel.value;
if (depProjectId && depDeploymentId)
requiredDeployments.push({
projectId: depProjectId,
deploymentId: depDeploymentId,
});
});
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,
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.requiredDeployments = requiredDeployments;
showToast(`استقرار ویرایش شد`, "success");
}
}
ModalManager.close("deploymentModal");
refreshUI();
this.showDetail(pid);
},
removeDeployment(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();
dep.status = "removed";
refreshUI();
this.showDetail(productId);
showToast("استقرار حذف شد", "warning");
},
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();
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");
},
};
/* ==================== 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;
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";
},
};
/* ==================== 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",
});
});
}
});
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",
});
});
});
});
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");
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) =>
d.type === "dependency" ? "#e0c789" : "#4a5568",
)
.attr("stroke-width", (d) => (d.type === "dependency" ? 2 : 1))
.attr("stroke-opacity", 0.6)
.attr("stroke-dasharray", (d) =>
d.type === "dependency" ? "5,3" : "none",
);
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);
}
});
sim.on("tick", () => {
this.linkElements
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.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>`;
});
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);
}
},
};
/* ==================== 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();
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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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);
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);
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, edc) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const data = Object.entries(edc).map(([k, v]) => ({
label: k,
value: v,
}));
if (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: 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", "#38a169")
.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", "#38a169");
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);
},
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 x = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, ih])
.padding(0.2);
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", 0)
.attr("y", (d) => y(d.label))
.attr("width", (d) => x(d.value))
.attr("height", y.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) => y(d.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((d) => d.label);
g.selectAll(".value")
.data(data)
.join("text")
.attr("x", (d) => x(d.value) + 5)
.attr("y", (d) => y(d.label) + y.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);
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 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]);
const colors = [
"#4299e1",
"#48bb78",
"#ecc94b",
"#ed64a6",
"#9f7aea",
"#ed8936",
];
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px")
.attr("font-family", "Vazirmatn");
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", (x, i) => colors[i % colors.length])
.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 (e, x) {
d3.select(this).attr(
"fill",
colors[entries.indexOf(x) % colors.length],
);
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]);
},
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);
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 x = d3
.scaleLinear()
.domain([0, d3.max(entries, (x) => x[1]) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(entries.map((x) => x[0]))
.range([0, ih])
.padding(0.2);
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", 0)
.attr("y", (x) => y(x[0]))
.attr("width", (x) => x(x[1]))
.attr("height", y.bandwidth())
.attr("fill", (x) => typeColors[x[0]] || "#718096")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${typeLabels[x[0]] || x[0]}: ${x[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, x) {
d3.select(this).attr("fill", typeColors[x[0]] || "#718096");
hideD3Tooltip();
});
g.selectAll(".label")
.data(entries)
.join("text")
.attr("x", -8)
.attr("y", (x) => y(x[0]) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((x) => typeLabels[x[0]] || x[0]);
g.selectAll(".value")
.data(entries)
.join("text")
.attr("x", (x) => x(x[1]) + 5)
.attr("y", (x) => y(x[0]) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((x) => x[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", () => {
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>