Files
xReleaseAsistant/xReleaseAssistant_7.html
T
2026-09-21 20:51:28 +03:30

11299 lines
403 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: 95vw;
width: 100%;
height: 95vh;
max-height: 95vh;
border-radius: 16px;
}
.galaxy-search {
position: relative;
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0 8px;
height: 32px;
min-width: 230px;
}
.galaxy-search-icon {
font-size: 12px;
opacity: 0.85;
}
.galaxy-search input {
flex: 1;
width: 140px;
background: transparent;
border: none;
color: #fff;
font-family: inherit;
font-size: 12px;
outline: none;
}
.galaxy-collapse-badge {
pointer-events: none;
}
.galaxy-search input::placeholder {
color: rgba(255, 255, 255, 0.55);
}
.galaxy-search-clear {
background: none;
border: none;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-size: 12px;
opacity: 0;
pointer-events: none;
}
.galaxy-search-clear.visible {
opacity: 1;
pointer-events: auto;
}
.galaxy-node.galaxy-dim {
opacity: 0.12;
}
.galaxy-node.galaxy-match > circle:nth-of-type(2) {
stroke: #ffd700;
stroke-width: 3;
}
.galaxy-link.galaxy-dim {
stroke-opacity: 0.04 !important;
}
.modal-header {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid var(--accent);
flex-wrap: wrap;
gap: 10px;
}
.modal-title {
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: rgba(255, 255, 255, 0.15);
border: none;
color: #fff;
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
}
.modal-close:hover {
background: var(--danger);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-start;
gap: 8px;
background: var(--border-light);
align-items: center;
flex-wrap: wrap;
}
.detail-section {
margin-bottom: 20px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.detail-section-header {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 12px 16px;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
}
.detail-section-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-section-body {
padding: 16px;
background: var(--card-bg);
}
.detail-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.detail-info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-info-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
}
.detail-info-value {
font-size: 13px;
font-weight: 600;
}
.subproject-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.subproject-card {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--border-light);
}
.subproject-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.subproject-title {
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.subproject-actions {
display: flex;
gap: 4px;
}
.subproject-meta {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
}
.deployment-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.deployment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.deployment-header-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.deployment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.deployment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--card-bg);
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 11px;
flex-wrap: wrap;
gap: 6px;
}
.deployment-item-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.deployment-env-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.deployment-version {
font-family: "Vazirmatn", monospace;
font-weight: 700;
color: var(--primary);
font-size: 11px;
}
[data-theme="dark"] .deployment-version {
color: var(--accent);
}
.deployment-date {
font-size: 10px;
color: var(--text-muted);
}
.deployment-actions {
display: flex;
gap: 4px;
}
.deployment-notes {
font-size: 10px;
color: var(--text-muted);
width: 100%;
margin-top: 4px;
padding-top: 4px;
border-top: 1px dashed var(--border-light);
line-height: 1.5;
}
.deployment-req {
font-size: 10px;
color: var(--info);
width: 100%;
margin-top: 2px;
}
.empty-deployments {
text-align: center;
padding: 12px;
color: var(--text-muted);
font-size: 11px;
background: var(--border-light);
border-radius: 6px;
}
.empty-subprojects {
text-align: center;
padding: 30px;
color: var(--text-muted);
background: var(--border-light);
border-radius: var(--radius-sm);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--primary);
margin-bottom: 6px;
}
[data-theme="dark"] .form-label {
color: var(--accent-light);
}
.form-label .required {
color: var(--danger);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
background: var(--card-bg);
color: var(--text);
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(201, 169, 97, 0.15);
}
.form-textarea {
resize: vertical;
min-height: 70px;
line-height: 1.6;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.form-error {
font-size: 11px;
color: var(--danger);
margin-top: 4px;
display: none;
}
.form-error.visible {
display: block;
}
.color-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.color-input-wrapper input[type="color"] {
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.color-preview-text {
font-size: 11px;
font-family: monospace;
color: var(--text-muted);
}
.webhook-section {
background: var(--border-light);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
margin-top: 12px;
}
.webhook-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.webhook-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary);
display: flex;
align-items: center;
gap: 6px;
}
.webhook-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
}
.switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--border);
transition: 0.3s;
border-radius: 20px;
}
.slider::before {
position: absolute;
content: "";
height: 14px;
width: 14px;
right: 3px;
bottom: 3px;
background: #fff;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background: var(--success);
}
input:checked + .slider::before {
transform: translateX(-16px);
}
.webhook-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
background: var(--card-bg);
border-radius: 4px;
font-size: 11px;
color: var(--text-muted);
}
.webhook-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.webhook-status-dot.success {
background: var(--success);
}
.webhook-status-dot.error {
background: var(--danger);
}
.toast-container {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: var(--primary);
color: #fff;
padding: 12px 18px;
border-radius: 8px;
box-shadow: var(--shadow-lg);
font-size: 13px;
display: flex;
align-items: center;
gap: 10px;
min-width: 250px;
}
.toast.success {
background: var(--success);
}
.toast.error {
background: var(--danger);
}
.toast.warning {
background: var(--warning);
}
.app-footer {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 8px 20px;
font-size: 11px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 2px solid var(--accent);
flex-shrink: 0;
}
.footer-brand {
color: var(--accent);
font-weight: 700;
}
.form-input[readonly],
.form-textarea[readonly] {
background: var(--border-light);
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.85;
border-style: dashed;
}
.readonly-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background: var(--border-light);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 10px;
margin-right: 6px;
}
.readonly-badge::before {
content: "🔒";
font-size: 10px;
}
.readonly-info {
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
border: 1px solid #90cdf4;
border-right: 4px solid var(--info);
padding: 10px 14px;
border-radius: 6px;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.dependency-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
max-height: 200px;
overflow-y: auto;
}
.dependency-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.dependency-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.dependency-project-name {
font-weight: 600;
font-size: 13px;
}
.dependency-search-box {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.dependency-search-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
background: var(--card-bg);
color: var(--text);
margin-bottom: 12px;
}
.dependency-search-results {
max-height: 300px;
overflow-y: auto;
}
.dependency-project-result {
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.dependency-project-add-btn {
padding: 10px 12px;
background: var(--card-bg);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
width: 100%;
border: none;
font-family: inherit;
text-align: right;
}
.dependency-project-add-btn:hover {
background: var(--accent-light);
}
.empty-dependencies {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-size: 12px;
background: var(--border-light);
border-radius: var(--radius-sm);
}
.dependency-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
[data-theme="dark"] .dependency-section-title {
color: var(--accent);
}
.env-color-preview {
padding: 10px;
border-radius: 6px;
text-align: center;
font-weight: 700;
border: 1px solid var(--border);
}
.env-read-only-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
padding: 6px 10px;
border-radius: 6px;
}
.req-deployment-box {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px;
margin-bottom: 8px;
background: var(--border-light);
}
.req-deployment-title {
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.req-deployment-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 12px;
background: var(--card-bg);
color: var(--text);
}
.galaxy-toolbar {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.galaxy-toolbar .btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 11px;
padding: 5px 10px;
}
.galaxy-toolbar .btn:hover {
background: var(--accent);
color: var(--primary-dark);
}
.galaxy-modal-body {
padding: 0;
position: relative;
overflow: hidden;
background: #0a0e27;
}
.galaxy-chart-container {
width: 100%;
height: 100%;
min-height: 400px;
position: relative;
}
.galaxy-chart-container svg {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
.galaxy-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.5);
text-align: center;
font-size: 16px;
gap: 10px;
}
.galaxy-legend {
position: absolute;
bottom: 16px;
right: 16px;
background: rgba(10, 14, 39, 0.85);
border: 1px solid rgba(201, 169, 97, 0.3);
border-radius: 10px;
padding: 12px 16px;
color: #fff;
font-size: 11px;
z-index: 10;
max-width: 220px;
}
.galaxy-legend-title {
font-weight: 700;
font-size: 12px;
margin-bottom: 10px;
color: var(--accent-light);
}
.galaxy-legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.galaxy-legend-color {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.galaxy-info-panel {
position: absolute;
top: 16px;
left: 16px;
background: rgba(10, 14, 39, 0.9);
border: 1px solid rgba(201, 169, 97, 0.3);
border-radius: 10px;
padding: 14px 18px;
color: #fff;
font-size: 12px;
z-index: 10;
max-width: 340px;
max-height: 400px;
overflow-y: auto;
display: none;
}
.galaxy-info-panel.visible {
display: block;
}
.galaxy-info-panel h4 {
font-size: 14px;
font-weight: 700;
color: var(--accent-light);
margin-bottom: 10px;
}
.galaxy-info-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
gap: 12px;
}
.galaxy-info-label {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.galaxy-info-value {
font-weight: 600;
font-size: 11px;
text-align: left;
}
.galaxy-zoom-indicator {
position: absolute;
bottom: 16px;
left: 16px;
background: rgba(10, 14, 39, 0.85);
border: 1px solid rgba(201, 169, 97, 0.3);
border-radius: 8px;
padding: 6px 12px;
color: #fff;
font-size: 12px;
z-index: 10;
font-weight: 600;
}
.galaxy-stats {
font-size: 12px;
color: var(--text-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.galaxy-stat-value {
font-weight: 700;
color: var(--primary);
background: rgba(26, 54, 93, 0.1);
padding: 2px 8px;
border-radius: 8px;
}
[data-theme="dark"] .galaxy-stat-value {
color: var(--accent);
background: rgba(201, 169, 97, 0.15);
}
.galaxy-search-results {
position: absolute;
top: 60px;
right: 16px;
width: 320px;
max-height: 320px;
overflow-y: auto;
background: rgba(10, 14, 39, 0.95);
border: 1px solid rgba(201, 169, 97, 0.35);
border-radius: 10px;
color: #fff;
z-index: 20;
display: none;
padding: 6px;
backdrop-filter: blur(10px);
}
.galaxy-search-results.visible {
display: block;
}
.galaxy-search-result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.galaxy-search-result-item:hover {
background: rgba(201, 169, 97, 0.25);
}
.galaxy-search-result-icon {
font-size: 16px;
flex-shrink: 0;
}
.galaxy-search-result-text {
min-width: 0;
}
.galaxy-search-result-title {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.galaxy-search-result-sub {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.galaxy-search-empty {
padding: 14px;
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.galaxy-node.galaxy-focus > circle:nth-of-type(2) {
stroke: #ffd700;
stroke-width: 4;
animation: galaxyPulse 0.9s ease-in-out 2;
}
.galaxy-node circle {
transform-box: fill-box;
transform-origin: center;
}
@keyframes galaxyPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
.dashboard-modal-body {
padding: 20px;
background: var(--bg);
overflow-y: auto;
}
.dashboard-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.dashboard-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.dashboard-kpi-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
position: relative;
overflow: hidden;
}
.dashboard-kpi-card::before {
content: "";
position: absolute;
top: 0;
right: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary), var(--accent));
}
.dashboard-kpi-icon {
font-size: 28px;
margin-bottom: 8px;
}
.dashboard-kpi-value {
font-size: 28px;
font-weight: 800;
color: var(--primary);
line-height: 1;
margin-bottom: 4px;
}
[data-theme="dark"] .dashboard-kpi-value {
color: var(--accent);
}
.dashboard-kpi-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
}
.dashboard-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.dashboard-chart-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.dashboard-chart-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--primary);
}
[data-theme="dark"] .dashboard-chart-header {
color: var(--accent);
}
.dashboard-chart-body {
padding: 16px;
display: flex;
justify-content: center;
align-items: center;
height: 320px;
min-height: 320px;
max-height: 320px;
overflow: auto;
position: relative;
}
.dashboard-chart-body svg {
max-width: 100%;
height: auto;
}
.dashboard-empty-chart {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
gap: 8px;
text-align: center;
padding: 20px;
}
.chart-zoom-container {
transform-origin: center center;
transition: transform 0.15s ease;
cursor: grab;
}
.chart-zoom-container:active {
cursor: grabbing;
}
.chart-zoom-indicator {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(26, 54, 93, 0.85);
color: #fff;
padding: 3px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 5;
}
.chart-zoom-indicator.visible {
opacity: 1;
}
.chart-zoom-reset {
position: absolute;
top: 8px;
right: 8px;
background: rgba(26, 54, 93, 0.85);
color: #fff;
border: none;
padding: 4px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s;
z-index: 5;
font-family: inherit;
}
.chart-zoom-reset.visible {
opacity: 1;
}
.chart-zoom-reset:hover {
background: var(--accent);
color: var(--primary-dark);
}
.dashboard-empty-chart span {
font-size: 32px;
opacity: 0.4;
}
.dashboard-footer-stats {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.dashboard-footer-stats .stat-highlight {
font-weight: 700;
color: var(--primary);
}
[data-theme="dark"] .dashboard-footer-stats .stat-highlight {
color: var(--accent);
}
.d3-tooltip {
position: fixed;
background: rgba(15, 35, 65, 0.95);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
pointer-events: none;
z-index: 10000;
display: none;
font-family: "Vazirmatn", sans-serif;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(201, 169, 97, 0.4);
max-width: 280px;
word-wrap: break-word;
direction: rtl;
backdrop-filter: none;
}
.d3-tooltip.visible {
display: block;
animation: tooltipFadeIn 0.15s ease;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.rb-toolbar {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
padding: 8px 12px;
background: linear-gradient(
135deg,
var(--primary-dark),
var(--primary)
);
border-bottom: 1px solid var(--border);
}
.rb-toolbar .btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 11px;
padding: 5px 10px;
}
.rb-toolbar .btn:hover {
background: var(--accent);
color: var(--primary-dark);
}
.rb-toolbar .btn.btn-accent {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: var(--primary-dark);
font-weight: 700;
}
.rb-toolbar .btn.btn-danger {
background: linear-gradient(135deg, #f56565, #e53e3e);
color: #fff;
}
.rb-toolbar-separator {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
.rb-search-box {
position: relative;
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 0 10px;
height: 32px;
min-width: 200px;
}
.rb-search-box input {
flex: 1;
width: 120px;
background: transparent;
border: none;
color: #fff;
font-family: inherit;
font-size: 12px;
outline: none;
}
.rb-search-box input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.rb-search-clear {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 12px;
opacity: 0;
pointer-events: none;
padding: 2px 4px;
}
.rb-search-clear.visible {
opacity: 1;
pointer-events: auto;
}
.rb-search-clear:hover {
color: #fff;
}
.rb-filter-status {
font-size: 10px;
color: var(--accent);
background: rgba(201, 169, 97, 0.15);
padding: 3px 8px;
border-radius: 10px;
font-weight: 700;
display: none;
}
.rb-filter-status.visible {
display: inline-flex;
align-items: center;
gap: 4px;
}
.rb-tag-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: linear-gradient(
135deg,
rgba(15, 23, 36, 0.95),
rgba(26, 42, 68, 0.95)
);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
min-height: 40px;
}
.rb-tag-filter-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.rb-filter-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 14px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
white-space: nowrap;
}
.rb-filter-chip:hover {
background: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.rb-filter-chip.active {
background: var(--chip-color, #4299e1);
color: #fff;
border-color: transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.rb-filter-chip .chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.rb-filter-chip.active .chip-dot {
background: #fff !important;
}
.rb-filter-clear-btn {
background: rgba(229, 62, 62, 0.2);
border: 1px solid rgba(229, 62, 62, 0.4);
color: #feb2b2;
font-size: 10px;
padding: 3px 8px;
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-weight: 600;
display: none;
}
.rb-filter-clear-btn.visible {
display: inline-flex;
align-items: center;
gap: 4px;
}
.rb-filter-clear-btn:hover {
background: rgba(229, 62, 62, 0.4);
}
.rb-filter-empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.rb-tabs-bar {
background: linear-gradient(
135deg,
var(--primary-dark) 0%,
var(--primary) 100%
);
border-bottom: 2px solid var(--accent);
display: flex;
align-items: center;
padding: 0 10px;
min-height: 42px;
gap: 3px;
overflow-x: auto;
}
.rb-tabs-bar::-webkit-scrollbar {
height: 4px;
}
.rb-tabs-bar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.rb-tab-item {
padding: 7px 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
border-radius: 6px 6px 0 0;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition: all 0.2s;
border-bottom: 2px solid transparent;
}
.rb-tab-item:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.rb-tab-item.active {
background: var(--accent);
color: var(--primary-dark);
font-weight: 700;
border-bottom-color: var(--primary-dark);
}
.rb-tab-close {
width: 18px;
height: 18px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: all 0.2s;
}
.rb-tab-item:hover .rb-tab-close {
opacity: 1;
}
.rb-tab-close:hover {
background: var(--danger);
color: #fff;
}
.rb-tab-add {
background: rgba(255, 255, 255, 0.08);
border: 1px dashed rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 700;
transition: all 0.2s;
}
.rb-tab-add:hover {
background: var(--accent);
color: var(--primary-dark);
border-color: var(--accent);
}
.rb-empty-tabs {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
padding: 0 10px;
}
.rb-board-area {
flex: 1;
overflow: auto;
padding: 20px;
background: var(--board-bg);
}
.rb-board-wrapper {
display: flex;
gap: 15px;
align-items: flex-start;
min-height: calc(100% - 40px);
padding-bottom: 20px;
}
.rb-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
.rb-empty-state-icon {
font-size: 80px;
opacity: 0.3;
margin-bottom: 20px;
}
.rb-default-columns-switch {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--border-light);
}
.rb-switch-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
user-select: none;
}
.rb-switch-info {
flex: 1;
min-width: 0;
}
.rb-switch-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
margin-bottom: 2px;
}
.rb-switch-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.rb-default-columns-preview {
display: flex;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
flex-wrap: wrap;
animation: rbFadeIn 0.3s ease;
}
.rb-default-columns-preview.hidden {
display: none;
}
.rb-preview-column {
flex: 1;
min-width: 80px;
padding: 8px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
text-align: center;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
transition: transform 0.2s;
}
.rb-preview-column:hover {
transform: translateY(-2px);
}
@keyframes rbFadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.column {
border-radius: var(--radius);
min-width: 300px;
max-width: 300px;
max-height: calc(100% - 20px);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
transition: all 0.2s;
background: var(--column-bg);
}
.column-header {
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.3),
transparent
);
border-radius: var(--radius) var(--radius) 0 0;
}
.column-title {
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.column-count {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 700;
background: rgba(0, 0, 0, 0.15);
color: inherit;
flex-shrink: 0;
}
.column-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.column-action-btn {
background: transparent;
border: none;
cursor: pointer;
color: inherit;
opacity: 0.7;
padding: 4px 6px;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s;
}
.column-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
opacity: 1;
}
.column-body {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 60px;
}
.column-body::-webkit-scrollbar {
width: 6px;
}
.column-body::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.column-footer {
padding: 8px;
border-top: 1px solid var(--border);
}
.add-card-btn {
width: 100%;
background: transparent;
border: 1px dashed var(--border);
color: var(--text-muted);
padding: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.add-card-btn:hover {
background: var(--card-bg);
border-color: var(--accent);
color: var(--accent-dark);
}
.add-column-btn {
background: rgba(255, 255, 255, 0.5);
border: 2px dashed var(--border);
border-radius: var(--radius);
min-width: 280px;
min-height: 100px;
cursor: pointer;
color: var(--text-muted);
font-family: inherit;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
flex-shrink: 0;
}
[data-theme="dark"] .add-column-btn {
background: rgba(255, 255, 255, 0.05);
}
.add-column-btn:hover {
background: var(--card-bg);
border-color: var(--accent);
color: var(--accent-dark);
}
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
box-shadow: var(--shadow-sm);
cursor: pointer;
border: 1px solid var(--border);
transition: all 0.15s;
position: relative;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
border-color: var(--accent);
}
.card:active {
cursor: grabbing;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.card-tag {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
color: white;
display: inline-block;
}
.card-title {
font-weight: 700;
font-size: 13px;
margin-bottom: 6px;
line-height: 1.5;
color: var(--text);
}
.card-desc {
font-size: 12px;
margin-bottom: 8px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
opacity: 0.85;
color: var(--text-muted);
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
align-items: center;
opacity: 0.9;
margin-top: 6px;
}
.card-meta-item {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(0, 0, 0, 0.08);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
}
[data-theme="dark"] .card-meta-item {
background: rgba(255, 255, 255, 0.15);
color: var(--text);
}
.card-meta-item.overdue {
background: #fed7d7;
color: var(--danger);
font-weight: 600;
opacity: 1;
}
.card-meta-item.soon {
background: #fef5e7;
color: var(--warning);
font-weight: 600;
opacity: 1;
}
[data-theme="dark"] .card-meta-item.overdue {
background: rgba(229, 62, 62, 0.3);
color: #feb2b2;
}
[data-theme="dark"] .card-meta-item.soon {
background: rgba(221, 107, 32, 0.3);
color: #fbd38d;
}
.card-checklist-progress {
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
}
[data-theme="dark"] .card-checklist-progress {
background: rgba(255, 255, 255, 0.15);
}
.card-checklist-bar {
height: 100%;
background: linear-gradient(90deg, var(--success), #48bb78);
transition: width 0.3s;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: white;
transition: all 0.15s;
border: 2px solid transparent;
user-select: none;
}
.tag-chip.selected {
border-color: var(--primary);
transform: scale(1.05);
box-shadow:
0 0 0 2px var(--card-bg),
0 0 0 4px var(--primary);
}
.tag-chip:hover {
opacity: 0.85;
}
.rb-tag-add-form {
background: var(--border-light);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 16px;
}
.rb-tag-list-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.rb-tag-count {
background: var(--primary);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 700;
}
.rb-tag-list {
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.rb-tag-list::-webkit-scrollbar {
width: 6px;
}
.rb-tag-list::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.rb-tag-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 0.15s;
}
.rb-tag-item:hover {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
.rb-tag-color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid rgba(0, 0, 0, 0.1);
}
.rb-tag-name {
flex: 1;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.rb-tag-usage {
font-size: 11px;
color: var(--text-muted);
background: var(--border-light);
padding: 2px 8px;
border-radius: 10px;
}
.rb-tag-actions {
display: flex;
gap: 4px;
}
.rb-tag-action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
opacity: 0.6;
transition: all 0.15s;
}
.rb-tag-action-btn:hover {
opacity: 1;
background: var(--border-light);
}
.rb-tag-action-btn.danger:hover {
background: #fed7d7;
}
.rb-tag-empty {
text-align: center;
padding: 30px 10px;
color: var(--text-muted);
font-size: 13px;
}
.rb-tag-empty-icon {
font-size: 40px;
opacity: 0.3;
margin-bottom: 10px;
}
.rb-board-area .empty-state {
text-align: center;
padding: 20px 10px;
color: var(--text-muted);
font-size: 12px;
}
.card.hidden-by-filter {
display: none !important;
}
.card.dragging {
opacity: 0.4;
transform: rotate(3deg);
box-shadow: var(--shadow-lg);
border-style: dashed;
}
.column.drag-over {
outline: 3px dashed var(--accent);
outline-offset: -3px;
background: rgba(201, 169, 97, 0.08) !important;
}
.column.drag-over .column-body {
background: rgba(201, 169, 97, 0.05);
}
.column.dragging-column {
opacity: 0.5;
border-style: dashed;
}
.column.drag-over-column {
outline: 3px dashed var(--info);
outline-offset: -3px;
}
.column-header[draggable="true"] {
cursor: grab;
}
.column-header[draggable="true"]:active {
cursor: grabbing;
}
.drag-handle-hint {
opacity: 0.4;
font-size: 12px;
margin-left: 4px;
cursor: grab;
}
.column-drop-indicator {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background: var(--accent);
border-radius: 2px;
z-index: 10;
}
/* ── حالت خالی هنگام فیلتر ── */
.column-filter-empty {
text-align: center;
padding: 15px 10px;
color: var(--text-muted);
font-size: 11px;
background: rgba(0, 0, 0, 0.03);
border-radius: 6px;
margin-top: 4px;
display: none;
}
.column-filter-empty.visible {
display: block;
}
/* ── استایل آیتم‌های وابستگی و مسئول ── */
.rb-item-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 14px;
font-size: 11px;
font-weight: 600;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text);
transition: all 0.2s;
}
.rb-item-chip:hover {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
.rb-item-chip .chip-remove {
cursor: pointer;
opacity: 0.6;
font-size: 12px;
transition: opacity 0.2s;
}
.rb-item-chip .chip-remove:hover {
opacity: 1;
color: var(--danger);
}
.rb-item-chip.assignee-chip {
background: #e6f4ff;
border-color: #91caff;
color: #0958d9;
}
.rb-item-chip.dependency-chip {
background: #fff7e6;
border-color: #ffd591;
color: #d46b08;
}
/* ── استایل تاریخچه ── */
.rb-history-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-light);
font-size: 11px;
line-height: 1.5;
}
.rb-history-item:last-child {
border-bottom: none;
}
.rb-history-icon {
font-size: 13px;
flex-shrink: 0;
margin-top: 1px;
}
.rb-history-content {
flex: 1;
color: var(--text);
}
.rb-history-date {
color: var(--text-muted);
font-size: 10px;
white-space: nowrap;
}
/* ── استایل نشانگر وابستگی در کارت ── */
.card-deps {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 6px;
}
.card-dep-badge {
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
background: #fff7e6;
border: 1px solid #ffd591;
color: #d46b08;
font-weight: 600;
}
.card-assignees {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 4px;
}
.card-assignee-badge {
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
background: #e6f4ff;
border: 1px solid #91caff;
color: #0958d9;
font-weight: 600;
}
/* ── اصلاح فیلتر: غیرفعال/فعال ── */
.rb-filter-clear-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
</style>
</head>
<body>
<header class="app-header">
<div class="app-brand">
<div class="app-logo">📊</div>
<div class="app-brand-text">
<div class="app-title" id="headerTitle">دستیار انتشار</div>
<div class="app-subtitle" id="headerSubtitle">
مدیریت نسخه‌ها و نیازمندی‌های محصولات
</div>
</div>
</div>
</header>
<div class="tabs-bar" id="advancedToolbar">
<div class="toolbar-controls">
<button class="toolbar-icon-btn" id="sidebarToggle" title="سایدبار">
<span id="sidebarIcon">✕</span>
</button>
<button class="toolbar-icon-btn" id="themeToggle" title="تم">
<span id="themeIcon">🌙</span>
</button>
<button class="toolbar-icon-btn" id="undoBtn" title="Ctrl+Z" disabled>
↩
</button>
<button class="toolbar-icon-btn" id="redoBtn" title="Ctrl+Y" disabled>
↪
</button>
</div>
<div class="toolbar-search">
<span style="position: absolute; right: 10px; opacity: 0.7">🔍</span>
<input
type="text"
class="search-input"
id="toolbarSearch"
placeholder="جستجو..."
/>
</div>
<div class="toolbar-filters">
<div class="filter-dropdown">
<span>🏷️</span>
<select class="filter-select" id="filterType">
<option value="">همه انواع</option>
<option value="backend">بک‌اند</option>
<option value="frontend">فرانت‌اند</option>
<option value="service">میکروسرویس</option>
<option value="database">پایگاه داده</option>
<option value="mobile">موبایل</option>
<option value="infrastructure">زیرساخت</option>
</select>
</div>
<div class="filter-dropdown">
<span>🌐</span>
<select class="filter-select" id="filterEnvironment">
<option value="">همه محیط‌ها</option>
</select>
</div>
<div class="filter-dropdown">
<span>👤</span>
<select class="filter-select" id="filterOwner">
<option value="">همه مالکان</option>
</select>
</div>
</div>
<div class="toolbar-stats">
<div class="stat-item">
📋 <span class="stat-value" id="statTotal">0</span>
</div>
<div class="stat-item">
📦 <span class="stat-value" id="statProjects">0</span>
</div>
<div class="stat-item">
🌿 <span class="stat-value" id="statSubProjects">0</span>
</div>
<div class="stat-item">
🔗 <span class="stat-value" id="statDependencies">0</span>
</div>
</div>
</div>
<div class="app-main">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">🛠️ ابزارها</div>
</div>
<div class="sidebar-body" id="sidebarBody"></div>
</aside>
<main class="board-area" id="boardArea"></main>
</div>
<footer class="app-footer" id="appFooter">
<div>
<span class="footer-brand">طراح و توسعه‌دهنده:</span> هادی خزاعی اصل
<span style="margin: 0 10px">|</span
><span class="footer-brand">شرکت:</span> رایان پرداز کاوش
</div>
<div><span class="footer-brand">نسخه:</span> 1.2.0</div>
</footer>
<!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">
<span>⚙️</span><span>تنظیمات محصول</span>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="readonly-info">
ℹ️ اطلاعات پایه محصول فقط قابل مشاهده هستند.
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>عنوان محصول
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingProjectTitle"
readonly
/>
</div>
<div class="form-group">
<label class="form-label"
>نسخه <span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingVersion"
readonly
/>
</div>
</div>
<div class="form-group">
<label class="form-label"
>توضیحات <span class="readonly-badge">فقط خواندنی</span></label
><textarea
class="form-textarea"
id="settingDescription"
readonly
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>توسعه‌دهنده
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingDeveloper"
readonly
/>
</div>
<div class="form-group">
<label class="form-label"
>شرکت <span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingCompany"
readonly
/>
</div>
</div>
<div class="webhook-section">
<div class="webhook-section-header">
<div class="webhook-section-title">🔗 تنظیمات وب‌هوک</div>
<div class="webhook-toggle">
<span>فعال‌سازی</span
><label class="switch"
><input type="checkbox" id="settingWebhookEnabled" /><span
class="slider"
></span
></label>
</div>
</div>
<div class="form-group">
<label class="form-label"
>آدرس URL وب‌هوک <span class="required">*</span></label
><input
type="url"
class="form-input"
id="settingWebhookUrl"
dir="ltr"
/>
</div>
<div class="form-group">
<label class="form-label"
>شناسه کلاینت <span class="required">*</span></label
><input
type="text"
class="form-input"
id="settingWebhookClientId"
dir="ltr"
/>
</div>
<div class="webhook-status">
<span class="webhook-status-dot" id="webhookStatusDot"></span
><span id="webhookStatusText">وضعیت: غیرفعال</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveSettingsBtn">💾 ذخیره</button>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Environment Modals -->
<div class="modal-overlay" id="envManagerModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">🌍 افزودن محیط عملیاتی جدید</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نام محیط <span class="required">*</span></label
><input type="text" class="form-input" id="newEnvName" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><input type="text" class="form-input" id="newEnvDesc" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">رنگ پس‌زمینه</label>
<div class="color-input-wrapper">
<input type="color" id="newEnvBgColor" value="#38a169" /><span
class="color-preview-text"
id="newEnvBgColorText"
>#38a169</span
>
</div>
</div>
<div class="form-group">
<label class="form-label">رنگ متن</label>
<div class="color-input-wrapper">
<input type="color" id="newEnvTextColor" value="#ffffff" /><span
class="color-preview-text"
id="newEnvTextColorText"
>#ffffff</span
>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveNewEnvBtn">💾 ثبت</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="envEditModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش محیط عملیاتی</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
><input type="text" class="form-input" id="editEnvId" readonly />
</div>
<div class="form-group">
<label class="form-label">نام <span class="required">*</span></label
><input type="text" class="form-input" id="editEnvName" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><input type="text" class="form-input" id="editEnvDesc" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">رنگ پس‌زمینه</label>
<div class="color-input-wrapper">
<input type="color" id="editEnvBgColor" /><span
class="color-preview-text"
id="editEnvBgColorText"
></span>
</div>
</div>
<div class="form-group">
<label class="form-label">رنگ متن</label>
<div class="color-input-wrapper">
<input type="color" id="editEnvTextColor" /><span
class="color-preview-text"
id="editEnvTextColorText"
></span>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">پیش‌نمایش</label>
<div id="editEnvPreview" class="env-color-preview">نمونه</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveEnvEditBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Product Modals -->
<div class="modal-overlay" id="projectDetailModal">
<div class="modal modal-xl">
<div class="modal-header">
<div class="modal-title" id="projectDetailTitle">📦 جزئیات محصول</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body" id="projectDetailBody"></div>
<div class="modal-footer">
<button class="btn btn-danger" id="projectDetailDeleteBtn">
🗑️ حذف محصول</button
><button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<div class="modal-overlay" id="productEditModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش محصول</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="editProductId"
readonly
/>
</div>
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
><input type="text" class="form-input" id="editProductTitle" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="editProductDescription"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">مالک</label
><input type="text" class="form-input" id="editProductOwner" />
</div>
<div class="form-group">
<label class="form-label"
>تاریخ ایجاد
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="editProductCreatedAt"
readonly
/>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveProductEditBtn">
💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="addProductModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">➕ ایجاد محصول جدید</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
><input type="text" class="form-input" id="newProductTitle" />
<div class="form-error" id="newProductTitleError">
عنوان محصول الزامی است و باید حداقل ۳ کاراکتر باشد
</div>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="newProductDescription"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">مالک</label
><input type="text" class="form-input" id="newProductOwner" />
</div>
<div class="form-group">
<label class="form-label"
>تاریخ ایجاد
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="newProductCreatedAt"
readonly
/>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveNewProductBtn">💾 ثبت</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="addProjectModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">➕ افزودن پروژه جدید</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
>
<input type="text" class="form-input" id="newProjectTitle" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نوع <span class="required">*</span></label
>
<select class="form-select" id="newProjectType">
<option value="">انتخاب کنید...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">فریم‌ورک</label>
<input type="text" class="form-input" id="newProjectFramework" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">مالک پروژه</label>
<input
type="text"
class="form-input"
id="newProjectOwner"
placeholder="نام مسئول پروژه..."
/>
</div>
<div class="form-group">
<label class="form-label">زبان</label>
<input type="text" class="form-input" id="newProjectLanguage" />
</div>
</div>
<div class="form-group">
<label class="form-label">آدرس مخزن</label>
<input
type="text"
class="form-input"
id="newProjectRepo"
dir="ltr"
/>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label>
<textarea
class="form-textarea"
id="newProjectDescription"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveNewProjectBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="editProjectModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش پروژه</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
>
<input type="text" class="form-input" id="editProjId" readonly />
</div>
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
>
<input type="text" class="form-input" id="editProjTitle" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نوع <span class="required">*</span></label
>
<select class="form-select" id="editProjType">
<option value="">انتخاب کنید...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">فریم‌ورک</label>
<input type="text" class="form-input" id="editProjFramework" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">مالک پروژه</label>
<input
type="text"
class="form-input"
id="editProjOwner"
placeholder="نام مسئول پروژه..."
/>
</div>
<div class="form-group">
<label class="form-label">زبان</label>
<input type="text" class="form-input" id="editProjLanguage" />
</div>
</div>
<div class="form-group">
<label class="form-label">آدرس مخزن</label>
<input type="text" class="form-input" id="editProjRepo" dir="ltr" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label>
<textarea class="form-textarea" id="editProjDescription"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveProjEditBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Deployment Modal -->
<div class="modal-overlay" id="deploymentModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title" id="deploymentModalTitle">
🚀 افزودن نسخه استقرار
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group" id="deploymentEnvGroup"></div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نسخه <span class="required">*</span></label
><input
type="text"
class="form-input"
id="depVersion"
dir="ltr"
placeholder="مثلاً: 1.2.3"
/>
<div class="form-hint">از فرمت SemVer استفاده کنید</div>
</div>
<div class="form-group">
<label class="form-label"
>تاریخ و زمان استقرار <span class="required">*</span></label
><input
type="datetime-local"
class="form-input"
id="depDate"
dir="ltr"
/>
</div>
</div>
<div class="form-group">
<label class="form-label"
>ریلیز نوت <span class="required">*</span></label
><textarea
class="form-textarea"
id="depReleaseNotes"
rows="3"
placeholder="توضیحات تغییرات این نسخه..."
></textarea>
<div class="form-hint">
شرح تغییرات، باگ‌فیکس‌ها و ویژگی‌های جدید این نسخه
</div>
</div>
<div id="requiredDeploymentsSection"></div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveDeploymentBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Dependency Manager Modal -->
<div class="modal-overlay" id="dependencyManagerModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">🔗 مدیریت وابستگی‌ها</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="readonly-info">
ℹ️ هر وابستگی فقط شامل یک پروژه است. انتخاب نسخه و محیط در زمان ثبت
استقرار انجام می‌شود.
</div>
<div class="dependency-section-title">📋 وابستگی‌های فعلی</div>
<div id="currentDependenciesList" class="dependency-list"></div>
<div class="dependency-search-box">
<div class="dependency-section-title">🔍 افزودن وابستگی جدید</div>
<input
type="text"
class="dependency-search-input"
id="dependencySearch"
placeholder="جستجو در پروژه‌ها..."
/>
<div
id="dependencySearchResults"
class="dependency-search-results"
></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Exploit Manager Modal -->
<div class="modal-overlay" id="exploitManagerModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">🔌 مدیریت سرویس‌های مورد استفاده</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="readonly-info">
ℹ️ سرویس‌هایی که این پروژه از آن‌ها استفاده می‌کند (ارتباط نرم).
انتخاب نسخه و محیط در زمان ثبت استقرار انجام می‌شود.
</div>
<div class="dependency-section-title">
📋 سرویس‌های مورد استفاده فعلی
</div>
<div id="currentExploitsList" class="dependency-list"></div>
<div class="dependency-search-box">
<div class="dependency-section-title">🔍 افزودن سرویس جدید</div>
<input
type="text"
class="dependency-search-input"
id="exploitSearch"
placeholder="جستجو در پروژه‌ها..."
/>
<div
id="exploitSearchResults"
class="dependency-search-results"
></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Galaxy Chart Modal -->
<div class="modal-overlay" id="galaxyChartModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🌌 نمودار کهکشانی محصولات</div>
<div class="galaxy-toolbar">
<div class="galaxy-search">
<span class="galaxy-search-icon">🔍</span>
<input
type="text"
id="galaxySearchInput"
placeholder="جستجو در نمودار..."
autocomplete="off"
/>
<button
class="galaxy-search-clear"
id="galaxySearchClear"
title="پاک کردن"
>
✕
</button>
</div>
<button
class="btn btn-sm"
id="galaxyCollapseAll"
title="کولاپس همه محصولات"
>
⊟ کولاپس همه
</button>
<button
class="btn btn-sm"
id="galaxyExpandAll"
title="باز کردن همه محصولات"
>
⊞ باز کردن همه
</button>
<button class="btn btn-sm" id="galaxyResetView">🎯 بازنشانی</button>
<button class="btn btn-sm" id="galaxyExportPNG">📷 PNG</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body galaxy-modal-body">
<div id="galaxyChartContainer" class="galaxy-chart-container"></div>
<div class="galaxy-search-results" id="galaxySearchResults"></div>
<div class="galaxy-legend" id="galaxyLegend"></div>
<div class="galaxy-info-panel" id="galaxyInfoPanel">
<button
class="modal-close"
id="galaxyInfoClose"
style="
position: absolute;
top: 8px;
left: 8px;
width: 22px;
height: 22px;
font-size: 12px;
"
>
✕
</button>
<div id="galaxyInfoContent"></div>
</div>
<div class="galaxy-zoom-indicator" id="galaxyZoomIndicator">
🔍 100%
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div class="galaxy-stats" id="galaxyStats"></div>
</div>
</div>
</div>
<!-- Executive Dashboard Modal -->
<div class="modal-overlay" id="executiveDashboardModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">📊 داشبورد مدیریتی</div>
<div class="galaxy-toolbar"></div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div id="dashboardContainer" class="dashboard-container"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div class="dashboard-footer-stats" id="dashboardFooterStats"></div>
</div>
</div>
</div>
<!-- Dependency Analysis Modal -->
<div class="modal-overlay" id="dependencyAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🔗 تحلیل وابستگی‌ها</div>
<div class="galaxy-toolbar"></div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div
id="dependencyAnalysisContainer"
class="dashboard-container"
></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div class="dashboard-footer-stats" id="depAnalysisFooterStats"></div>
</div>
</div>
</div>
<!-- Deployment Analysis Modal -->
<div class="modal-overlay" id="deploymentAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🚀 تحلیل استقرار</div>
<div class="galaxy-toolbar"></div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div
id="deploymentAnalysisContainer"
class="dashboard-container"
></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div
class="dashboard-footer-stats"
id="deployAnalysisFooterStats"
></div>
</div>
</div>
</div>
<!-- Version Analysis Modal -->
<div class="modal-overlay" id="versionAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🔢 تحلیل نسخه</div>
<div class="galaxy-toolbar"></div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div id="versionAnalysisContainer" class="dashboard-container"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div class="dashboard-footer-stats" id="verAnalysisFooterStats"></div>
</div>
</div>
</div>
<!-- Risk Analysis Modal -->
<div class="modal-overlay" id="riskAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">⚠️ تحلیل ریسک</div>
<div class="galaxy-toolbar"></div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div id="riskAnalysisContainer" class="dashboard-container"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div
class="dashboard-footer-stats"
id="riskAnalysisFooterStats"
></div>
</div>
</div>
</div>
<!-- Release Boards Modal -->
<div class="modal-overlay" id="releaseBoardsModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🚀 بردهای انتشار</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<!-- ✅ Toolbar با جستجو و فیلتر تگ -->
<div class="rb-toolbar">
<button class="btn" id="rbEditBoard" title="ویرایش برد فعلی">
✏️ ویرایش
</button>
<button class="btn" id="rbManageTags" title="مدیریت تگ‌ها">
🏷️ تگ‌ها
</button>
<div class="rb-toolbar-separator"></div>
<div class="rb-search-box">
<span style="opacity: 0.7; font-size: 12px">🔍</span>
<input
type="text"
id="rbCardSearchInput"
placeholder="جستجوی کارت‌ها..."
autocomplete="off"
/>
<button
class="rb-search-clear"
id="rbCardSearchClear"
title="پاک کردن"
>
✕
</button>
</div>
<span class="rb-filter-status" id="rbFilterStatus"
>📋 <span id="rbFilterCount">0</span>/<span id="rbFilterTotal"
>0</span
></span
>
<div class="rb-toolbar-separator"></div>
<button class="btn" id="rbUndoBtn" title="بازگشت (Ctrl+Z)" disabled>
↩️
</button>
<button class="btn" id="rbRedoBtn" title="از نو (Ctrl+Y)" disabled>
↪️
</button>
</div>
<div class="rb-tag-filter-bar" id="rbTagFilterBar">
<span class="rb-tag-filter-label">🏷️ فیلتر تگ:</span>
<div
id="rbTagFilterContainer"
style="
display: flex;
gap: 5px;
flex-wrap: wrap;
align-items: center;
"
></div>
<button class="rb-filter-clear-btn" id="rbClearTagFilter">
✕ حذف فیلتر
</button>
</div>
<!-- Tab Bar -->
<div class="rb-tabs-bar" id="rbTabsBar"></div>
<!-- Board Area -->
<div
class="modal-body"
style="
padding: 0;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
"
>
<div class="rb-board-area" id="rbBoardArea">
<div class="rb-empty-state" id="rbEmptyState">
<div class="rb-empty-state-icon">📋</div>
<h2 style="color: var(--primary); margin-bottom: 10px">
هیچ بردی وجود ندارد
</h2>
<p style="margin-bottom: 20px">
برای شروع، یک برد جدید ایجاد کنید
</p>
<button
class="btn btn-accent"
onclick="ReleaseBoardsManager.openNewBoardModal()"
>
➕ ایجاد اولین برد
</button>
</div>
<div
class="rb-board-wrapper"
id="rbBoardWrapper"
style="display: none"
></div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div class="dashboard-footer-stats" id="rbFooterStats"></div>
</div>
</div>
</div>
<!-- Modal: New/Edit Board -->
<div class="modal-overlay" id="rbBoardFormModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">
📋 <span id="rbBoardFormTitle">ایجاد برد جدید</span>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان برد <span class="required">*</span></label
><input
type="text"
class="form-input"
id="rbBoardTitle"
placeholder="مثلاً: اسپرینت جاری"
/>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="rbBoardDesc"
placeholder="توضیحات برد..."
></textarea>
</div>
<div class="form-group">
<label class="form-label">🎨 رنگ پس‌زمینه</label
><input
type="color"
class="form-input"
id="rbBoardBgColor"
value="#e2e8f0"
style="height: 40px"
/>
</div>
<div class="form-group" id="rbDefaultColumnsGroup">
<label class="form-label">📑 ستون‌های اولیه</label>
<div class="rb-default-columns-switch">
<label class="rb-switch-label">
<div class="rb-switch-info">
<div class="rb-switch-title">ایجاد با ستون‌های پیش‌فرض</div>
<div class="rb-switch-desc">
۵ ستون استاندارد کانبان به صورت خودکار ایجاد می‌شود
</div>
</div>
<label class="switch"
><input
type="checkbox"
id="rbUseDefaultColumns"
checked /><span class="slider"></span
></label>
</label>
<div
class="rb-default-columns-preview"
id="rbDefaultColumnsPreview"
>
<div
class="rb-preview-column"
style="background: #6b7208; color: #b4cbf9"
>
📥 بک لاگ
</div>
<div
class="rb-preview-column"
style="background: #393e65; color: #b4cbf9"
>
📋 برای انجام
</div>
<div
class="rb-preview-column"
style="background: #453111; color: #b4cbf9"
>
🔨 در حال انجام
</div>
<div
class="rb-preview-column"
style="background: #542881; color: #b4cbf9"
>
🔍 برای تست
</div>
<div
class="rb-preview-column"
style="background: #087224; color: #d8d96d"
>
✅ انجام شده
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="rbSaveBoardBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Modal: New/Edit Column -->
<div class="modal-overlay" id="rbColumnFormModal">
<div class="modal" style="max-width: 500px">
<div class="modal-header">
<div class="modal-title">
📊 <span id="rbColumnFormTitle">ایجاد ستون جدید</span>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان ستون <span class="required">*</span></label
><input
type="text"
class="form-input"
id="rbColumnTitle"
placeholder="مثلاً: در حال انجام"
/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">🎨 رنگ پس‌زمینه</label
><input
type="color"
class="form-input"
id="rbColumnBgColor"
value="#edf2f7"
style="height: 40px"
/>
</div>
<div class="form-group">
<label class="form-label">🖋️ رنگ متن</label
><input
type="color"
class="form-input"
id="rbColumnTextColor"
value="#1a202c"
style="height: 40px"
/>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="rbSaveColumnBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Modal: New/Edit Card -->
<div class="modal-overlay" id="rbCardFormModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">
📝 <span id="rbCardFormTitle">ایجاد کارت جدید</span>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان کارت <span class="required">*</span></label
>
<input
type="text"
class="form-input"
id="rbCardTitle"
placeholder="عنوان وظیفه..."
/>
</div>
<div class="form-group">
<label class="form-label"
>توضیحات <span class="required">*</span></label
>
<textarea
class="form-textarea"
id="rbCardDesc"
placeholder="توضیحات کامل..."
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">📅 تاریخ تخمینی</label>
<input
type="datetime-local"
class="form-input"
id="rbCardDate"
dir="ltr"
/>
</div>
<div class="form-group">
<label class="form-label">🎨 رنگ کارت</label>
<input
type="color"
class="form-input"
id="rbCardBgColor"
value="#ffffff"
style="height: 40px"
/>
</div>
</div>
<div class="form-group">
<label class="form-label">👥 افراد مسئول</label>
<div
id="rbAssigneesContainer"
style="
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
min-height: 42px;
background: var(--border-light);
"
></div>
<div style="display: flex; gap: 6px; margin-top: 6px">
<input
type="text"
class="form-input"
id="rbNewAssigneeName"
placeholder="نام فرد مسئول..."
/>
<button
class="btn btn-sm"
onclick="ReleaseBoardsManager.addAssignee()"
>
➕
</button>
</div>
</div>
<div class="form-group">
<label class="form-label">🔗 وابستگی به کارت‌های دیگر</label>
<div
id="rbDependenciesContainer"
style="
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
min-height: 42px;
background: var(--border-light);
"
></div>
<div style="display: flex; gap: 6px; margin-top: 6px">
<select class="form-select" id="rbDependencySelect">
<option value="">انتخاب کارت برای وابستگی...</option>
</select>
<button
class="btn btn-sm"
onclick="ReleaseBoardsManager.addDependency()"
>
➕
</button>
</div>
</div>
<div class="form-group">
<label class="form-label">🏷️ برچسب‌ها</label>
<div
id="rbCardTagsContainer"
style="
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
min-height: 50px;
background: var(--border-light);
"
></div>
</div>
<div class="form-group">
<label class="form-label">✅ چک‌لیست</label>
<div
id="rbChecklistContainer"
style="
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
background: var(--border-light);
max-height: 180px;
overflow-y: auto;
"
></div>
<div style="display: flex; gap: 6px; margin-top: 6px">
<input
type="text"
class="form-input"
id="rbNewChecklistItem"
placeholder="ریزوظیفه جدید..."
/>
<button
class="btn btn-sm"
onclick="ReleaseBoardsManager.addChecklistItem()"
>
➕
</button>
</div>
</div>
<div class="form-group" id="rbCardHistoryGroup" style="display: none">
<label class="form-label">📜 تاریخچه عملیات</label>
<div
id="rbCardHistoryContainer"
style="
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
background: var(--border-light);
max-height: 160px;
overflow-y: auto;
font-size: 11px;
"
></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="rbSaveCardBtn">💾 ذخیره</button>
<button
class="btn btn-danger"
id="rbDeleteCardBtn"
style="display: none"
>
🗑️ حذف
</button>
<button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Modal: Tag Manager -->
<div class="modal-overlay" id="rbTagManagerModal">
<div class="modal" style="max-width: 550px">
<div class="modal-header">
<div class="modal-title">🏷️ مدیریت تگ‌های بردها</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="rb-tag-add-form">
<div class="form-row" style="gap: 8px; align-items: flex-end">
<div class="form-group" style="flex: 1; margin-bottom: 0">
<label class="form-label">نام تگ</label
><input
type="text"
class="form-input"
id="rbNewTagName"
placeholder="مثلاً: فوری، باگ، ویژگی..."
/>
</div>
<div class="form-group" style="width: 80px; margin-bottom: 0">
<label class="form-label">رنگ</label
><input
type="color"
class="form-input"
id="rbNewTagColor"
value="#4299e1"
style="height: 38px; padding: 2px"
/>
</div>
<button
class="btn btn-accent"
id="rbAddTagBtn"
style="height: 38px; white-space: nowrap"
>
➕ افزودن
</button>
</div>
</div>
<div class="rb-tag-list-header">
<span>تگ‌های موجود</span
><span class="rb-tag-count" id="rbTagCount">0</span>
</div>
<div class="rb-tag-list" id="rbTagList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
<div style="font-size: 11px; color: var(--text-muted)">
💡 تغییرات تگ‌ها به صورت خودکار در تمام کارت‌ها اعمال می‌شود
</div>
</div>
</div>
</div>
<!-- Modal: Edit Tag -->
<div class="modal-overlay" id="rbEditTagModal">
<div class="modal" style="max-width: 420px">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش تگ</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>نام تگ <span class="required">*</span></label
><input
type="text"
class="form-input"
id="rbEditTagName"
placeholder="نام تگ..."
/>
</div>
<div class="form-group">
<label class="form-label">🎨 رنگ</label
><input
type="color"
class="form-input"
id="rbEditTagColor"
value="#4299e1"
style="height: 40px"
/>
</div>
<div class="form-group">
<label class="form-label">پیش‌نمایش</label>
<div
style="
padding: 10px;
background: var(--border-light);
border-radius: 6px;
text-align: center;
"
>
<span
class="card-tag"
id="rbEditTagPreview"
style="font-size: 13px; padding: 4px 14px"
>نمونه</span
>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="rbSaveTagBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Modal: Autosave Recovery -->
<div class="modal-overlay" id="rbAutosaveModal">
<div class="modal" style="max-width: 550px">
<div class="modal-header">
<div class="modal-title">💾 بازیابی خودکار داده‌ها</div>
</div>
<div class="modal-body">
<div style="text-align: center; padding: 24px 16px">
<div style="font-size: 48px; margin-bottom: 16px">💾</div>
<div
style="
font-size: 14px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
"
>
داده‌هایی در حافظه موقت وجود دارند
</div>
<div style="font-size: 12px; color: var(--text-muted)">
آیا مایل به بازیابی آن‌ها هستید؟
</div>
</div>
</div>
<div class="modal-footer" style="justify-content: space-between">
<button class="btn btn-accent" id="rbRecoverAutosaveBtn">
💾 بازیابی
</button>
<button class="btn btn-danger" id="rbDiscardAutosaveBtn">
🗑️ نادیده گرفتن
</button>
</div>
</div>
</div>
<!-- Auto-save Indicator -->
<div
id="rbAutosaveIndicator"
style="
position: fixed;
bottom: 50px;
left: 20px;
background: var(--panel-bg);
color: var(--text);
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
z-index: 1500;
pointer-events: none;
"
>
<div
style="
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: rbPulse 2s infinite;
"
></div>
<span id="rbAutosaveText">ذخیره خودکار انجام شد</span>
</div>
<style>
@keyframes rbPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style>
<div class="toast-container" id="toastContainer"></div>
<div class="galaxy-tooltip" id="galaxyTooltip"></div>
<div class="d3-tooltip" id="d3Tooltip"></div>
<!-- Scripts -->
<script>
/* ==================== DATA ==================== */
const projectData = {
projectId: "550e8400-e29b-41d4-a716-446655440000",
projectName: "xReleaseAssistant",
projectTitle: "دستیار انتشار",
description: "مدیریت نسخه‌ها و نیازمندی‌های محصولات",
version: "1.1.0",
developer: "هادی خزاعی اصل",
company: "رایان پرداز کاوش",
metadata: {
createdAt: "2026-09-13T00:00:00Z",
updatedAt: "2026-09-18T00:00:00Z",
tags: ["release-management"],
customFields: {},
},
webhook: { url: "", clientId: "", enabled: false },
toolGroups: [
{
id: "product-management",
title: "پرونده",
icon: "📁",
description: "مدیریت چرخه حیات محصول",
tools: [
{
id: "clear-all",
title: "پاکسازی",
icon: "🗑️",
action: "clearAll",
description: "پاکسازی کامل",
},
{
id: "save-product",
title: "ذخیره",
icon: "💾",
action: "saveProduct",
description: "دانلود داده‌ها",
},
{
id: "load-product",
title: "بارگذاری",
icon: "📂",
action: "loadProduct",
description: "بارگذاری از فایل",
},
{
id: "product-settings",
title: "تنظیمات",
icon: "⚙️",
action: "openProductSettings",
description: "پیکربندی",
},
],
},
{
id: "boards",
title: "بردها",
icon: "📋",
description: "مدیریت بردهای انتشار",
tools: [
{
id: "release-boards",
title: "بردهای انتشار",
icon: "🚀",
action: "showReleaseBoards",
description: "مشاهده و مدیریت بردهای انتشار",
},
{
id: "tag-manager",
title: "مدیریت تگ‌ها",
icon: "🏷️",
action: "showTagManager",
description: "مدیریت تگ‌های بردها",
},
],
},
{
id: "reports",
title: "گزارش‌ها",
icon: "📊",
description: "تحلیل و گزارش‌گیری",
tools: [
{
id: "executive-dashboard",
title: "داشبورد مدیریتی",
icon: "📊",
action: "showExecutiveDashboard",
description: "نمای کلی با نمودارها",
},
{
id: "dependency-analysis",
title: "تحلیل وابستگی‌ها",
icon: "🔗",
action: "showDependencyAnalysis",
description: "تحلیل وابستگی‌ها",
},
{
id: "deployment-analysis",
title: "تحلیل استقرار",
icon: "🚀",
action: "showDeploymentAnalysis",
description: "تحلیل استقرارها",
},
{
id: "version-analysis",
title: "تحلیل نسخه",
icon: "🔢",
action: "showVersionAnalysis",
description: "تحلیل نسخه‌ها",
},
{
id: "risk-analysis",
title: "تحلیل ریسک",
icon: "⚠️",
action: "showRiskAnalysis",
description: "تحلیل ریسک‌ها",
},
{
id: "galaxy-chart",
title: "نمودار کهکشانی",
icon: "🌌",
action: "showGalaxyChart",
description: "نمودار کهکشانی",
},
],
},
],
appData: {
environments: [],
products: [],
boards: [],
boardTags: [],
},
};
const VALID_PROJECT_TYPES = [
{ value: "backend", label: "بک‌اند (Backend)", icon: "🖥️" },
{ value: "frontend", label: "فرانت‌اند (Frontend)", icon: "🌐" },
{ value: "service", label: "میکروسرویس (Service)", icon: "⚙️" },
{ value: "database", label: "پایگاه داده (Database)", icon: "🗄️" },
{ value: "mobile", label: "موبایل (Mobile)", icon: "📱" },
{
value: "infrastructure",
label: "زیرساخت (Infrastructure)",
icon: "🏗️",
},
];
/* ==================== HELPERS ==================== */
function validateAppDataStructure(d) {
return (
d &&
typeof d === "object" &&
Array.isArray(d.environments) &&
Array.isArray(d.products)
);
}
function populateProjectTypeDropdowns() {
["newProjectType", "editProjType"].forEach((id) => {
const s = document.getElementById(id);
if (!s) return;
const cv = s.value;
s.innerHTML =
'<option value="">انتخاب کنید...</option>' +
VALID_PROJECT_TYPES.map(
(t) => `<option value="${t.value}">${t.icon} ${t.label}</option>`,
).join("");
if (cv) s.value = cv;
});
}
function showToast(msg, type = "info", dur = 3000) {
const c = document.getElementById("toastContainer");
const t = document.createElement("div");
t.className = `toast ${type}`;
const ic = { success: "✅", error: "❌", warning: "⚠️", info: "ℹ️" };
t.innerHTML = `<span>${ic[type] || "ℹ️"}</span><span>${msg}</span>`;
c.appendChild(t);
setTimeout(() => {
t.style.opacity = "0";
setTimeout(() => t.remove(), 300);
}, dur);
}
function showD3Tooltip(e, text) {
let tt = document.getElementById("d3Tooltip");
if (!tt) return;
if (!text) {
hideD3Tooltip();
return;
}
tt.textContent = text;
tt.classList.add("visible");
const ttRect = tt.getBoundingClientRect();
const padding = 12;
let x = e.clientX + padding;
let y = e.clientY + padding;
if (x + ttRect.width > window.innerWidth) {
x = e.clientX - ttRect.width - padding;
}
if (y + ttRect.height > window.innerHeight) {
y = e.clientY - ttRect.height - padding;
}
tt.style.left = x + "px";
tt.style.top = y + "px";
}
function hideD3Tooltip() {
const tt = document.getElementById("d3Tooltip");
if (tt) tt.classList.remove("visible");
}
function refreshUI() {
ToolbarManager.updateStats();
BoardRenderer.render();
}
/* ==================== MODAL MANAGER ==================== */
const ModalManager = {
stack: [],
open(id) {
const m = document.getElementById(id);
if (!m) return;
this.stack = this.stack.filter((x) => x !== id);
this.stack.push(id);
this.stack.forEach((mid, i) => {
const el = document.getElementById(mid);
if (!el) return;
if (mid === id) el.classList.add("active");
el.style.zIndex = 1000 + i + 1;
});
},
close(id) {
const m = document.getElementById(id);
if (!m) return;
m.classList.remove("active");
m.style.zIndex = "";
this.stack = this.stack.filter((x) => x !== id);
this.stack.forEach((mid, i) => {
const el = document.getElementById(mid);
if (el) el.style.zIndex = 1000 + i + 1;
});
},
closeTop() {
if (this.stack.length > 0)
this.close(this.stack[this.stack.length - 1]);
},
init() {
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const m = e.target.closest(".modal-overlay");
if (m && m.id) this.close(m.id);
});
});
document.querySelectorAll(".modal-overlay").forEach((ov) => {
ov.addEventListener("click", (e) => {
if (e.target === ov && ov.id) this.close(ov.id);
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeTop();
}
});
},
};
/* ==================== TOOLBAR MANAGER ==================== */
const ToolbarManager = {
currentFilters: {
query: "",
typeFilter: "",
envFilter: "",
ownerFilter: "",
},
init() {
this.populateEnvironmentFilter();
this.populateOwnerFilter();
this.bindEvents();
this.updateStats();
},
populateEnvironmentFilter() {
const s = document.getElementById("filterEnvironment");
if (!s) return;
s.innerHTML =
'<option value="">همه محیط‌ها</option>' +
projectData.appData.environments
.map((e) => `<option value="${e.id}">${e.name}</option>`)
.join("");
},
populateOwnerFilter() {
const s = document.getElementById("filterOwner");
if (!s) return;
const owners = new Set();
(projectData.appData.products || []).forEach((p) => {
const owner = p.metadata?.owner?.trim();
if (owner) owners.add(owner);
});
const sortedOwners = Array.from(owners).sort((a, b) =>
a.localeCompare(b, "fa"),
);
s.innerHTML =
'<option value="">همه مالکان</option>' +
sortedOwners
.map((o) => `<option value="${o}">${o}</option>`)
.join("");
},
bindEvents() {
const si = document.getElementById("toolbarSearch"),
ft = document.getElementById("filterType"),
fe = document.getElementById("filterEnvironment"),
fo = document.getElementById("filterOwner");
if (si)
si.addEventListener("input", () => {
this.applyFilters();
refreshUI();
});
if (ft)
ft.addEventListener("change", () => {
this.applyFilters();
refreshUI();
});
if (fe)
fe.addEventListener("change", () => {
this.applyFilters();
refreshUI();
});
if (fo)
fo.addEventListener("change", () => {
this.applyFilters();
refreshUI();
});
},
applyFilters() {
this.currentFilters = {
query: (document.getElementById("toolbarSearch")?.value || "")
.trim()
.toLowerCase(),
typeFilter: document.getElementById("filterType")?.value || "",
envFilter:
document.getElementById("filterEnvironment")?.value || "",
ownerFilter: document.getElementById("filterOwner")?.value || "",
};
this.updateStats();
},
updateStats() {
const ps = projectData.appData.products || [];
let tp = 0,
td = 0;
ps.forEach((p) => {
(p.projects || []).forEach((pr) => {
tp++;
td += (pr.dependencies || []).length;
});
});
const s = (id, v) => {
const el = document.getElementById(id);
if (el) el.textContent = v;
};
s("statTotal", ps.length + tp);
s("statProjects", ps.length);
s("statSubProjects", tp);
s("statDependencies", td);
},
shouldShowProduct(product) {
const f = this.currentFilters;
if (!f.query && !f.typeFilter && !f.envFilter && !f.ownerFilter)
return true;
if (f.ownerFilter) {
const prodOwner = product.metadata?.owner?.trim();
if (prodOwner !== f.ownerFilter) return false;
}
if (!f.query && !f.typeFilter && !f.envFilter) return true;
return (product.projects || []).some((pr) => {
const mq =
!f.query ||
pr.title.toLowerCase().includes(f.query) ||
product.title.toLowerCase().includes(f.query);
const mt = !f.typeFilter || pr.type === f.typeFilter;
const me =
!f.envFilter ||
(pr.deployments || [])
.filter((d) => d.status === "active")
.some((d) => d.environmentId === f.envFilter);
return mq && mt && me;
});
},
};
/* ==================== HISTORY MANAGER ==================== */
const HistoryManager = {
undoStack: [],
redoStack: [],
maxSize: 50,
isUndoRedoInProgress: false,
_debounceTimer: null,
_pendingState: null,
pushState() {
if (this.isUndoRedoInProgress) return;
this._pendingState = projectData.appData;
if (this._debounceTimer) clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this._commitState();
}, 100);
},
_commitState() {
if (!this._pendingState) return;
let snapshot;
if (typeof structuredClone === "function")
snapshot = structuredClone(this._pendingState);
else snapshot = JSON.parse(JSON.stringify(this._pendingState));
this.undoStack.push(snapshot);
if (this.undoStack.length > this.maxSize) this.undoStack.shift();
this.redoStack = [];
this._pendingState = null;
this.updateButtons();
},
undo() {
if (this.undoStack.length === 0) {
showToast("هیچ تغییری نیست", "warning");
return;
}
if (this._pendingState) this._commitState();
this.redoStack.push(
structuredClone
? structuredClone(projectData.appData)
: JSON.parse(JSON.stringify(projectData.appData)),
);
this.isUndoRedoInProgress = true;
projectData.appData = this.undoStack.pop();
this.isUndoRedoInProgress = false;
this.updateUI();
this.updateButtons();
showToast("↩️ بازگشت", "success");
},
redo() {
if (this.redoStack.length === 0) {
showToast("هیچ تغییری نیست", "warning");
return;
}
if (this._pendingState) this._commitState();
this.undoStack.push(
structuredClone
? structuredClone(projectData.appData)
: JSON.parse(JSON.stringify(projectData.appData)),
);
this.isUndoRedoInProgress = true;
projectData.appData = this.redoStack.pop();
this.isUndoRedoInProgress = false;
this.updateUI();
this.updateButtons();
showToast("↪️ باز انجام", "success");
},
updateUI() {
refreshUI();
if (ProductManager.currentDetailProductId)
ProductManager.showDetail(ProductManager.currentDetailProductId);
},
updateButtons() {
const u = document.getElementById("undoBtn"),
r = document.getElementById("redoBtn");
if (u) u.disabled = this.undoStack.length === 0;
if (r) r.disabled = this.redoStack.length === 0;
},
init() {
this.pushState();
document
.getElementById("undoBtn")
.addEventListener("click", () => this.undo());
document
.getElementById("redoBtn")
.addEventListener("click", () => this.redo());
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
this.undo();
}
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "y" || (e.key === "z" && e.shiftKey))
) {
e.preventDefault();
this.redo();
}
});
},
};
/* ==================== SECTION COLLAPSE ==================== */
const SectionCollapseManager = {
storageKey: "boardSectionCollapseState",
getState() {
try {
const s = localStorage.getItem(this.storageKey);
return s
? JSON.parse(s)
: { dashboard: false, products: false, environments: false };
} catch {
return { dashboard: false, products: false, environments: false };
}
},
saveState(s) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(s));
} catch (e) {}
},
toggle(id) {
const s = this.getState();
s[id] = !s[id];
this.saveState(s);
const sec = document.getElementById(`section-${id}`);
if (sec) sec.classList.toggle("collapsed", s[id]);
return s[id];
},
init() {
const s = this.getState();
Object.keys(s).forEach((id) => {
const sec = document.getElementById(`section-${id}`);
if (sec && s[id]) sec.classList.add("collapsed");
});
document
.getElementById("boardArea")
.addEventListener("click", (e) => {
const ht = e.target.closest(".board-header-title");
if (ht) {
const sec = ht.closest(".board-section");
if (sec) this.toggle(sec.id.replace("section-", ""));
}
});
},
};
/* ==================== DASHBOARD SECTION ==================== */
const DashboardSectionRenderer = {
getSectionHTML() {
const d = this.getData();
let h = `<div class="board-section" id="section-dashboard">`;
h += `<div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📊 داشبورد مدیریتی</div><div class="board-header-actions"><button class="btn btn-accent" onclick="DashboardManager.open()">📊 مشاهده کامل</button></div></div>`;
h += `<div class="section-content">`;
h += '<div class="dashboard-kpi-row" style="margin-bottom:16px">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژه‌ها</div><div class="dashboard-chart-body" id="mainDashTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="mainDashEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژه‌ها</div><div class="dashboard-chart-body" id="mainDashStatusChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژه‌ها به تفکیک محصول</div><div class="dashboard-chart-body" id="mainDashProductChart"></div></div>';
h += "</div>";
h += "</div></div>";
return h;
},
getData() {
const ps = projectData.appData.products || [];
let tp = 0,
td = 0,
tdp = 0;
const tc = {},
edc = {},
ppc = [];
let pwd = 0,
pwod = 0;
ps.forEach((prod) => {
const projects = prod.projects || [];
ppc.push({ title: prod.title, count: projects.length });
projects.forEach((pr) => {
tp++;
td += (pr.dependencies || []).length;
const active = (pr.deployments || []).filter(
(d) => d.status === "active",
);
tdp += active.length;
if (active.length > 0) pwd++;
else pwod++;
tc[pr.type] = (tc[pr.type] || 0) + 1;
active.forEach((d) => {
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
edc[envName] = (edc[envName] || 0) + 1;
});
});
});
return {
products: ps.length,
projects: tp,
deps: td,
deploys: tdp,
environments: projectData.appData.environments.length,
tc,
edc,
ppc,
pwd,
pwod,
};
},
renderCharts() {
const d = this.getData();
this.renderDonut(
"mainDashTypeChart",
Object.entries(d.tc).map(([k, v]) => ({
label: this.getTypeLabel(k),
value: v,
color: this.getTypeColor(k),
})),
);
this.renderBar(
"mainDashEnvChart",
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
"#38a169",
);
this.renderDonut("mainDashStatusChart", [
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
]);
this.renderBar(
"mainDashProductChart",
d.ppc.map((p) => ({
label:
p.title.length > 15
? p.title.substring(0, 15) + "..."
: p.title,
value: p.count,
})),
"#3182ce",
);
},
renderDonut(cid, data) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 240,
hgt = 200,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((d) => d.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 6);
const total = data.reduce((s, d) => s + d.value, 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (d) => d.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).transition().duration(150).attr("d", arcH(d));
showD3Tooltip(
e,
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr("fill", "var(--text)")
.attr("font-size", "20px")
.attr("font-weight", "800")
.text(total);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.3em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.text("پروژه");
},
renderBar(cid, data, color) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 200,
m = { top: 20, right: 15, bottom: 40, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-20)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d) => x(d.label))
.attr("y", (d) => y(d.value))
.attr("width", x.bandwidth())
.attr("height", (d) => ih - y(d.value))
.attr("fill", color)
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d.label}: ${d.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", color);
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(data)
.join("text")
.attr("x", (d) => x(d.label) + x.bandwidth() / 2)
.attr("y", (d) => y(d.value) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.value);
},
getTypeLabel(t) {
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
return i ? i.label.split(" ")[0] : t;
},
getTypeColor(t) {
const c = {
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
};
return c[t] || "#718096";
},
};
/* ==================== ENVIRONMENT RENDERER ==================== */
const EnvironmentRenderer = {
getSectionHTML() {
const envs = projectData.appData.environments || [];
let h = `<div class="board-section" id="section-environments"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>🌍 محیط‌های عملیاتی (${envs.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="EnvironmentManager.openAddModal()">➕ محیط جدید</button></div></div><div class="section-content">`;
if (envs.length === 0) {
h +=
'<div class="board-empty"><div class="board-empty-icon">🌍</div><h2>هیچ محیطی نیست</h2></div>';
} else {
h += '<div class="env-card-grid">';
envs.forEach((env) => {
h += `<div class="env-card-item"><div class="env-card-preview" style="background-color:${env.backgroundColor || "#38a169"};color:${env.textColor || "#fff"}">${env.name}</div><div class="env-card-body"><div class="env-card-name">${env.name}</div><div class="env-card-desc">${env.description || ""}</div></div><div class="env-card-footer"><button class="btn btn-ghost btn-sm" onclick="EnvironmentManager.openEditModal('${env.id}')">✏️ ویرایش</button><button class="btn btn-danger btn-sm" onclick="EnvironmentManager.remove('${env.id}')">🗑️ حذف</button></div></div>`;
});
h += "</div>";
}
h += "</div></div>";
return h;
},
};
/* ==================== BOARD RENDERER ==================== */
const BoardRenderer = {
render() {
const board = document.getElementById("boardArea");
const products = projectData.appData.products || [];
const fp = products.filter((p) =>
ToolbarManager.shouldShowProduct(p),
);
let h = "";
h += DashboardSectionRenderer.getSectionHTML();
h += `<div class="board-section" id="section-products"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📦 محصولات (${fp.length} از ${products.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="ProductManager.openAddProductModal()">➕ محصول جدید</button></div></div><div class="section-content">`;
if (products.length === 0) {
h +=
'<div class="board-empty"><div class="board-empty-icon">📦</div><h2>هیچ محصولی نیست</h2></div>';
} else if (fp.length === 0) {
h += '<div class="no-results">🔍 نتیجه‌ای یافت نشد</div>';
} else {
h += '<div class="projects-grid">';
fp.forEach((prod) => {
const projects = prod.projects || [];
const tc = {};
projects.forEach((pr) => {
tc[pr.type] = (tc[pr.type] || 0) + 1;
});
const td = projects.reduce(
(s, pr) => s + (pr.dependencies || []).length,
0,
);
const tdp = projects.reduce(
(s, pr) =>
s +
(pr.deployments || []).filter((d) => d.status === "active")
.length,
0,
);
h += `<div class="project-card"><div class="project-card-header"><div class="project-card-title-row"><span style="font-size:22px">📦</span><div class="project-card-title">${prod.title}</div></div><div class="project-card-desc">${prod.description || ""}</div></div><div class="project-card-body"><div class="project-stat-row"><span>🌿 پروژه‌ها:</span><span class="project-stat-value">${projects.length}</span></div><div class="project-stat-row"><span>🔗 وابستگی‌ها:</span><span class="project-stat-value">${td}</span></div><div class="project-stat-row"><span>🚀 استقرارها:</span><span class="project-stat-value">${tdp}</span></div>${
Object.keys(tc).length > 0
? `<div class="project-type-badges">${Object.entries(tc)
.map(([type, count]) => {
const ti = VALID_PROJECT_TYPES.find(
(t) => t.value === type,
) || { label: type };
return `<span class="type-badge type-${type}">${ti.label}: ${count}</span>`;
})
.join("")}</div>`
: ""
}</div><div class="project-card-footer"><div class="card-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.showDetail('${prod.id}')">👁️ جزئیات</button></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeProduct('${prod.id}')">🗑️</button></div></div>`;
});
h += "</div>";
}
h += "</div></div>";
h += EnvironmentRenderer.getSectionHTML();
board.innerHTML = h;
const st = SectionCollapseManager.getState();
Object.keys(st).forEach((id) => {
const sec = document.getElementById(`section-${id}`);
if (sec && st[id]) sec.classList.add("collapsed");
});
setTimeout(() => {
DashboardSectionRenderer.renderCharts();
}, 100);
},
};
/* ==================== ACTION REGISTRY ==================== */
const ActionRegistry = {
openProductSettings() {
openSettingsModal();
},
saveProduct() {
projectData.metadata.updatedAt = new Date().toISOString();
const js = JSON.stringify(projectData.appData, null, 2);
const b = new Blob([js], { type: "application/json;charset=utf-8" });
const u = URL.createObjectURL(b);
const a = document.createElement("a");
a.href = u;
a.download = `${projectData.projectName}_v${projectData.version}_${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(u);
showToast("💾 ذخیره شد", "success");
},
loadProduct() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.addEventListener("change", (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = (ev) => {
try {
const ld = JSON.parse(ev.target.result);
if (!validateAppDataStructure(ld)) {
showToast("❌ ساختار نامعتبر", "error");
return;
}
(ld.products || []).forEach((prod) => {
(prod.projects || []).forEach((pr) => {
if (!pr.exploits) {
pr.exploits = [];
}
});
});
HistoryManager.pushState();
projectData.appData = ld;
AutosaveManager.clear();
AutosaveManager.save();
updateProjectUI();
renderToolGroups();
ToolbarManager.populateEnvironmentFilter();
ToolbarManager.populateOwnerFilter();
refreshUI();
showToast("📂 بارگذاری شد", "success");
} catch (err) {
showToast("❌ خطا: " + err.message, "error");
}
};
r.readAsText(f);
});
inp.click();
},
clearAll() {
if (!confirm("⚠️ پاکسازی کامل؟ این عمل غیرقابل بازگشت است!")) return;
if (!confirm("⚠️ تأیید نهایی؟")) return;
HistoryManager.pushState();
projectData.appData = {
environments: [],
products: [],
boards: [],
boardTags: [],
};
AutosaveManager.clear();
ToolbarManager.populateEnvironmentFilter();
ToolbarManager.populateOwnerFilter();
refreshUI();
showToast("🗑️ پاکسازی شد", "success");
},
showGalaxyChart() {
GalaxyChartManager.open();
},
showExecutiveDashboard() {
DashboardManager.open();
},
showDependencyAnalysis() {
DependencyAnalysisManager.open();
},
showDeploymentAnalysis() {
DeploymentAnalysisManager.open();
},
showVersionAnalysis() {
VersionAnalysisManager.open();
},
showRiskAnalysis() {
RiskAnalysisManager.open();
},
showReleaseBoards() {
ReleaseBoardsManager.open();
},
showTagManager() {
ReleaseBoardsManager.openTagManager();
},
defaultAction(tool) {
showToast(`ابزار: ${tool.title}`, "info");
},
};
/* ==================== WEBHOOK MANAGER ==================== */
const WebhookManager = {
getConfig() {
return projectData.webhook;
},
setConfig(c) {
projectData.webhook = { ...projectData.webhook, ...c };
this.updateStatusUI();
},
isValid() {
const c = this.getConfig();
return (
c.enabled && c.url && c.clientId && /^https?:\/\//i.test(c.url)
);
},
updateStatusUI() {
const c = this.getConfig();
const d = document.getElementById("webhookStatusDot"),
t = document.getElementById("webhookStatusText");
if (!d || !t) return;
d.className = "webhook-status-dot";
if (!c.enabled) t.textContent = "وضعیت: غیرفعال";
else if (!c.url || !c.clientId) {
d.classList.add("pending");
t.textContent = "وضعیت: ناقص";
} else {
d.classList.add("success");
t.textContent = "وضعیت: آماده";
}
},
async sendEvent(en, payload = {}) {
if (!this.isValid()) return;
const c = this.getConfig();
try {
await fetch(c.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Client-Id": c.clientId,
"X-Event-Name": en,
},
body: JSON.stringify({
event: en,
timestamp: new Date().toISOString(),
projectId: projectData.projectId,
data: payload,
}),
});
} catch (e) {}
},
};
/* ==================== ENVIRONMENT MANAGER ==================== */
const EnvironmentManager = {
currentEditEnvId: null,
openAddModal() {
document.getElementById("newEnvName").value = "";
document.getElementById("newEnvDesc").value = "";
document.getElementById("newEnvBgColor").value = "#38a169";
document.getElementById("newEnvTextColor").value = "#ffffff";
ModalManager.open("envManagerModal");
},
saveNewEnv() {
const n = document.getElementById("newEnvName").value.trim();
if (!n) {
showToast("نام الزامی است", "error");
return;
}
if (
projectData.appData.environments.some(
(e) => e.name.toLowerCase() === n.toLowerCase(),
)
) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.environments.push({
id: `env-${Date.now()}`,
name: n,
description:
document.getElementById("newEnvDesc").value.trim() || "",
backgroundColor: document.getElementById("newEnvBgColor").value,
textColor: document.getElementById("newEnvTextColor").value,
});
ModalManager.close("envManagerModal");
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast(`محیط "${n}" اضافه شد`, "success");
},
openEditModal(envId) {
const env = projectData.appData.environments.find(
(e) => e.id === envId,
);
if (!env) return;
this.currentEditEnvId = envId;
document.getElementById("editEnvId").value = env.id;
document.getElementById("editEnvName").value = env.name;
document.getElementById("editEnvDesc").value = env.description || "";
document.getElementById("editEnvBgColor").value =
env.backgroundColor || "#38a169";
document.getElementById("editEnvTextColor").value =
env.textColor || "#ffffff";
this.updateEditPreview();
ModalManager.open("envEditModal");
},
updateEditPreview() {
const p = document.getElementById("editEnvPreview");
p.style.backgroundColor =
document.getElementById("editEnvBgColor").value;
p.style.color = document.getElementById("editEnvTextColor").value;
},
saveEdit() {
const env = projectData.appData.environments.find(
(e) => e.id === this.currentEditEnvId,
);
if (!env) return;
const nn = document.getElementById("editEnvName").value.trim();
if (!nn) {
showToast("نام الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
env.name = nn;
env.description = document.getElementById("editEnvDesc").value.trim();
env.backgroundColor = document.getElementById("editEnvBgColor").value;
env.textColor = document.getElementById("editEnvTextColor").value;
ModalManager.close("envEditModal");
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast(`محیط "${env.name}" ویرایش شد`, "success");
},
remove(id) {
const env = projectData.appData.environments.find((e) => e.id === id);
if (!env) return;
if (!confirm(`حذف "${env.name}"؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.environments =
projectData.appData.environments.filter((e) => e.id !== id);
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast(`"${env.name}" حذف شد`, "warning");
},
};
/* ==================== PRODUCT MANAGER ==================== */
const ProductManager = {
currentDetailProductId: null,
currentEditProjectId: null,
currentDependencyProjectId: null,
currentDependencyProjId: null,
currentDeploymentProjId: null,
currentDeploymentMode: null,
currentDeploymentEnvId: null,
currentEditDeploymentId: null,
currentExploitProjectId: null,
currentExploitProjId: null,
getTypeInfo(type) {
return (
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
value: type,
label: type,
icon: "❓",
}
);
},
getEnvironmentName(envId) {
const e = projectData.appData.environments.find(
(x) => x.id === envId,
);
return e ? e.name : envId;
},
getEnvironmentColors(envId) {
const e = projectData.appData.environments.find(
(x) => x.id === envId,
);
return {
backgroundColor: e?.backgroundColor || "#38a169",
textColor: e?.textColor || "#ffffff",
};
},
findProjectById(pid) {
for (const prod of projectData.appData.products) {
const pr = prod.projects.find((p) => p.id === pid);
if (pr) return pr;
}
return null;
},
getActiveDeployments(proj) {
return (proj.deployments || [])
.filter((d) => d.status === "active")
.sort((a, b) => {
const dateA = a.deploymentDate
? new Date(a.deploymentDate).getTime()
: 0;
const dateB = b.deploymentDate
? new Date(b.deploymentDate).getTime()
: 0;
return dateB - dateA;
});
},
getEnvironmentsWithoutDeployment(proj) {
const allEnvIds = projectData.appData.environments.map((e) => e.id);
const deployedEnvIds = this.getActiveDeployments(proj).map(
(d) => d.environmentId,
);
return allEnvIds.filter((id) => !deployedEnvIds.includes(id));
},
showDetail(productId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
this.currentDetailProductId = productId;
document.getElementById("projectDetailTitle").innerHTML =
`<span>📦</span><span>${prod.title}</span>`;
const projects = prod.projects || [];
const totalDeploys = projects.reduce(
(s, pr) => s + this.getActiveDeployments(pr).length,
0,
);
let html = `<div class="detail-section"><div class="detail-section-header"><span>ℹ️ اطلاعات محصول</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openProductEditModal()">✏️ ویرایش</button></div></div><div class="detail-section-body"><div class="detail-info-grid"><div class="detail-info-item"><span class="detail-info-label">عنوان</span><span class="detail-info-value">${prod.title}</span></div><div class="detail-info-item"><span class="detail-info-label">توضیحات</span><span class="detail-info-value">${prod.description || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تاریخ ایجاد</span><span class="detail-info-value">${prod.metadata?.createdAt ? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR") : "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">مالک</span><span class="detail-info-value">${prod.metadata?.owner || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تعداد پروژه</span><span class="detail-info-value">${projects.length}</span></div><div class="detail-info-item"><span class="detail-info-label">استقرارهای فعال</span><span class="detail-info-value">${totalDeploys}</span></div></div></div></div><div class="detail-section"><div class="detail-section-header"><span>🌿 پروژه‌ها (${projects.length})</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openAddProjectModal()">➕ افزودن</button></div></div><div class="detail-section-body">`;
if (projects.length === 0) {
html += '<div class="empty-subprojects">🌿 هیچ پروژه‌ای نیست</div>';
} else {
html += '<div class="subproject-list">';
projects.forEach((pr) => {
const ti = this.getTypeInfo(pr.type);
const deps = pr.dependencies || [];
const exploits = pr.exploits || [];
const deploys = this.getActiveDeployments(pr);
const archivedDeploys = (pr.deployments || [])
.filter((d) => d.status === "archived")
.sort((a, b) => {
const dateA = a.archivedAt
? new Date(a.archivedAt).getTime()
: 0;
const dateB = b.archivedAt
? new Date(b.archivedAt).getTime()
: 0;
return dateB - dateA;
});
const hasEnvs = projectData.appData.environments.length > 0;
html += `<div class="subproject-card"><div class="subproject-header"><div class="subproject-title"><span>${ti.icon}</span><span>${pr.title}</span><span class="type-badge type-${pr.type}">${ti.label}</span></div><div class="subproject-actions"><button class="btn btn-ghost btn-sm" onclick="ProductManager.openDependencyManager('${prod.id}','${pr.id}')">🔗 وابستگی‌ها</button><button class="btn btn-ghost btn-sm" onclick="ProductManager.openExploitManager('${prod.id}','${pr.id}')">🔌 سرویس‌های وابسته</button><button class="btn btn-ghost btn-sm" onclick="ProductManager.openProjectEditModal('${prod.id}','${pr.id}')">✏️</button><button class="btn btn-danger btn-sm" onclick="ProductManager.removeProject('${prod.id}','${pr.id}')">🗑️</button></div></div><div class="subproject-meta">${pr.description || ""}</div><div class="detail-info-grid" style="margin-bottom:8px">`;
if (pr.metadata?.owner) {
html += `<div class="detail-info-item"><span class="detail-info-label">👤 مالک</span><span class="detail-info-value">${pr.metadata.owner}</span></div>`;
}
if (pr.metadata?.framework) {
html += `<div class="detail-info-item"><span class="detail-info-label">فریم‌ورک</span><span class="detail-info-value">${pr.metadata.framework}</span></div>`;
}
if (pr.metadata?.language) {
html += `<div class="detail-info-item"><span class="detail-info-label">زبان</span><span class="detail-info-value">${pr.metadata.language}</span></div>`;
}
if (deps.length > 0) {
html += `<div class="detail-info-item"><span class="detail-info-label">وابستگی‌ها (${deps.length})</span><span class="detail-info-value">${deps
.map((depId) => {
const dp = this.findProjectById(depId);
return dp ? dp.title : depId;
})
.join("، ")}</span></div>`;
}
if (exploits.length > 0) {
html += `<div class="detail-info-item"><span class="detail-info-label">سرویس‌های مورد استفاده (${exploits.length})</span><span class="detail-info-value">${exploits
.map((expId) => {
const ep = this.findProjectById(expId);
return ep ? ep.title : expId;
})
.join("، ")}</span></div>`;
}
html += `</div>`;
html += `<div class="deployment-section"><div class="deployment-header"><span class="deployment-header-title">🚀 استقرارهای فعال (${deploys.length})</span>${hasEnvs ? `<button class="btn btn-accent btn-xs" onclick="ProductManager.openAddDeploymentModal('${prod.id}','${pr.id}')">➕ افزودن</button>` : '<span style="font-size:10px;color:var(--text-muted)">⚠️ محیطی تعریف نشده</span>'}</div>`;
if (deploys.length === 0) {
html +=
'<div class="empty-deployments">هنوز استقرار ندارد</div>';
} else {
html += '<div class="deployment-list">';
deploys.forEach((d) => {
const en = this.getEnvironmentName(d.environmentId);
const cl = this.getEnvironmentColors(d.environmentId);
const fd = d.deploymentDate
? new Date(d.deploymentDate).toLocaleString("fa-IR")
: "—";
html += `<div class="deployment-item"><div class="deployment-item-info"><span class="deployment-env-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${en}</span><span class="deployment-version">v${d.version}</span><span class="deployment-date">📅 ${fd}</span></div><div class="deployment-actions">`;
html += `<button class="btn btn-ghost btn-xs" onclick="ProductManager.openEditDeploymentModal('${prod.id}','${pr.id}','${d.id}')" title="ویرایش">✏️</button>`;
html += `<button class="btn btn-xs" style="background:#dd6b20;color:#fff" onclick="ProductManager.archiveDeployment('${prod.id}','${pr.id}','${d.id}')" title="انتقال به تاریخچه">📦 آرشیو</button>`;
html += `<button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')" title="حذف کامل">🗑️</button>`;
html += `</div>`;
if (d.releaseNotes) {
html += `<div class="deployment-notes">📝 ${d.releaseNotes}</div>`;
}
if (
d.requiredDeployments &&
d.requiredDeployments.length > 0
) {
html += `<div class="deployment-req">🔗 وابستگی‌ها: ${d.requiredDeployments
.map((rd) => {
const rp = this.findProjectById(rd.projectId);
let depVer = "";
if (rp) {
const rdDep = (rp.deployments || []).find(
(x) => x.id === rd.deploymentId,
);
if (rdDep) depVer = ` (v${rdDep.version})`;
}
return `${rp ? rp.title : rd.projectId}${depVer}`;
})
.join("، ")}</div>`;
}
if (d.requiredExploits && d.requiredExploits.length > 0) {
html += `<div class="deployment-req" style="color:var(--info)">🔌 سرویس‌ها: ${d.requiredExploits
.map((re) => {
const rp = this.findProjectById(re.projectId);
let depVer = "";
if (rp) {
const reDep = (rp.deployments || []).find(
(x) => x.id === re.deploymentId,
);
if (reDep) depVer = ` (v${reDep.version})`;
}
return `${rp ? rp.title : re.projectId}${depVer}`;
})
.join("، ")}</div>`;
}
html += `</div>`;
});
html += "</div>";
}
html += `<div class="deployment-section" style="margin-top:12px;border-top:2px dashed var(--border)">`;
html += `<div class="deployment-header">`;
html += `<span class="deployment-header-title">📜 تاریخچه استقرارها (${archivedDeploys.length})</span>`;
if (archivedDeploys.length > 0) {
html += `<button class="btn btn-ghost btn-xs" onclick="ProductManager.toggleArchiveVisibility('${pr.id}')" id="archiveToggle-${pr.id}">👁️ نمایش/مخفی</button>`;
}
html += `</div>`;
if (archivedDeploys.length === 0) {
html += `<div class="empty-deployments" style="opacity:0.6">هنوز استقراری آرشیو نشده است</div>`;
} else {
html += `<div class="deployment-list" id="archiveList-${pr.id}" style="display:none">`;
archivedDeploys.forEach((d) => {
const en = this.getEnvironmentName(d.environmentId);
const cl = this.getEnvironmentColors(d.environmentId);
const fd = d.deploymentDate
? new Date(d.deploymentDate).toLocaleString("fa-IR")
: "—";
const archivedDate = d.archivedAt
? new Date(d.archivedAt).toLocaleString("fa-IR")
: "—";
html += `<div class="deployment-item" style="opacity:0.75;background:var(--border-light)">`;
html += `<div class="deployment-item-info">`;
html += `<span class="deployment-env-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor};opacity:0.7">${en}</span>`;
html += `<span class="deployment-version" style="opacity:0.7">v${d.version}</span>`;
html += `<span class="deployment-date">📅 ${fd}</span>`;
html += `<span class="deployment-date" style="color:var(--warning)">📦 آرشیو: ${archivedDate}</span>`;
html += `</div>`;
html += `<div class="deployment-actions">`;
html += `<button class="btn btn-xs" style="background:#3182ce;color:#fff" onclick="ProductManager.restoreDeployment('${prod.id}','${pr.id}','${d.id}')" title="بازگرداندن به لیست فعال">↩️ بازگردانی</button>`;
html += `<button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')" title="حذف کامل">🗑️</button>`;
html += `</div>`;
if (d.releaseNotes) {
html += `<div class="deployment-notes" style="opacity:0.7">📝 ${d.releaseNotes}</div>`;
}
html += `</div>`;
});
html += `</div>`;
}
html += `</div>`;
html += `</div></div>`;
});
html += "</div>";
}
html += `</div></div>`;
document.getElementById("projectDetailBody").innerHTML = html;
ModalManager.open("projectDetailModal");
},
openAddProductModal() {
document.getElementById("newProductTitle").value = "";
document.getElementById("newProductDescription").value = "";
document.getElementById("newProductOwner").value = "";
document.getElementById("newProductCreatedAt").value =
new Date().toLocaleDateString("fa-IR");
ModalManager.open("addProductModal");
},
createNewProduct() {
const title = document.getElementById("newProductTitle").value.trim();
if (!title || title.length < 3) {
showToast("عنوان الزامی است (حداقل ۳ کاراکتر)", "error");
return;
}
if (
projectData.appData.products.some(
(p) => p.title.toLowerCase() === title.toLowerCase(),
)
) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.products.push({
id: `prod-${Date.now()}`,
title,
description:
document.getElementById("newProductDescription").value.trim() ||
"",
metadata: {
createdAt: new Date().toISOString(),
owner:
document.getElementById("newProductOwner").value.trim() || "",
customFields: {},
},
projects: [],
});
ModalManager.close("addProductModal");
refreshUI();
ToolbarManager.populateOwnerFilter();
showToast(`"${title}" ایجاد شد`, "success");
},
openDependencyManager(productId, projId) {
this.currentDependencyProjectId = productId;
this.currentDependencyProjId = projId;
this.renderCurrentDependencies();
this.renderAllProjectsList();
this.setupDependencySearch();
ModalManager.open("dependencyManagerModal");
},
renderCurrentDependencies() {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!pr) return;
const c = document.getElementById("currentDependenciesList");
const deps = pr.dependencies || [];
if (deps.length === 0) {
c.innerHTML =
'<div class="empty-dependencies">هیچ وابستگی‌ای نیست</div>';
return;
}
c.innerHTML = deps
.map((depId, index) => {
const depPr = this.findProjectById(depId);
const projectName = depPr ? depPr.title : depId;
const typeInfo = depPr ? this.getTypeInfo(depPr.type) : null;
return `<div class="dependency-item"><div class="dependency-info"><div><div class="dependency-project-name">${typeInfo ? typeInfo.icon + " " : ""}${projectName}</div></div></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDependency(${index})">🗑️</button></div>`;
})
.join("");
},
removeDependency(index) {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!pr) return;
if (!confirm("حذف این وابستگی؟")) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
pr.dependencies.splice(index, 1);
ModalManager.close("dependencyManagerModal");
refreshUI();
if (this.currentDetailProductId)
this.showDetail(this.currentDetailProductId);
showToast("وابستگی حذف شد", "success");
},
renderAllProjectsList() {
const rc = document.getElementById("dependencySearchResults");
const cp = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!cp) return;
const cpr = cp.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!cpr) return;
const allProjects = [];
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
if (pr.id !== cpr.id)
allProjects.push({ ...pr, productName: prod.title });
});
});
if (allProjects.length === 0) {
rc.innerHTML =
'<div class="empty-dependencies">پروژه دیگری نیست</div>';
return;
}
rc.innerHTML = allProjects
.map((pr) => {
const isDep = (cpr.dependencies || []).includes(pr.id);
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted);font-weight:normal">(${pr.productName})</small>${isDep ? '<span style="margin-right:auto;font-size:10px;color:var(--success)">✓ وابسته</span>' : ""}</button></div>`;
})
.join("");
},
setupDependencySearch() {
const si = document.getElementById("dependencySearch");
si.value = "";
si.oninput = (e) => {
const q = e.target.value.trim().toLowerCase();
if (q.length === 0) {
this.renderAllProjectsList();
return;
}
const cp = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!cp) return;
const cpr = cp.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!cpr) return;
const allProjects = [];
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
if (pr.id !== cpr.id)
allProjects.push({ ...pr, productName: prod.title });
});
});
const fl = allProjects.filter(
(pr) =>
pr.title.toLowerCase().includes(q) ||
pr.productName.toLowerCase().includes(q) ||
(pr.description || "").toLowerCase().includes(q),
);
const rc = document.getElementById("dependencySearchResults");
if (fl.length === 0) {
rc.innerHTML =
'<div class="empty-dependencies">نتیجه‌ای نیست</div>';
return;
}
rc.innerHTML = fl
.map((pr) => {
const isDep = (cpr.dependencies || []).includes(pr.id);
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted)">(${pr.productName})</small></button></div>`;
})
.join("");
};
},
addDependency(projectId) {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!pr) return;
if ((pr.dependencies || []).includes(projectId)) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (!pr.dependencies) pr.dependencies = [];
pr.dependencies.push(projectId);
ModalManager.close("dependencyManagerModal");
refreshUI();
if (this.currentDetailProductId)
this.showDetail(this.currentDetailProductId);
showToast("وابستگی اضافه شد", "success");
},
openProductEditModal() {
const pid = this.currentDetailProductId;
if (!pid) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
document.getElementById("editProductId").value = prod.id;
document.getElementById("editProductTitle").value = prod.title;
document.getElementById("editProductDescription").value =
prod.description || "";
document.getElementById("editProductOwner").value =
prod.metadata?.owner || "";
document.getElementById("editProductCreatedAt").value = prod.metadata
?.createdAt
? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR")
: "—";
ModalManager.open("productEditModal");
},
saveProductEdit() {
const pid = this.currentDetailProductId;
if (!pid) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const nt = document.getElementById("editProductTitle").value.trim();
if (!nt) {
showToast("عنوان الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
prod.title = nt;
prod.description = document
.getElementById("editProductDescription")
.value.trim();
if (!prod.metadata) prod.metadata = {};
prod.metadata.owner = document
.getElementById("editProductOwner")
.value.trim();
prod.metadata.updatedAt = new Date().toISOString();
ModalManager.close("productEditModal");
refreshUI();
ToolbarManager.populateOwnerFilter();
this.showDetail(pid);
showToast(`"${prod.title}" ویرایش شد`, "success");
},
openAddProjectModal() {
if (!this.currentDetailProductId) {
showToast("ابتدا محصول انتخاب کنید", "warning");
return;
}
document.getElementById("newProjectTitle").value = "";
document.getElementById("newProjectType").value = "";
document.getElementById("newProjectFramework").value = "";
document.getElementById("newProjectOwner").value = ""; // 🆕
document.getElementById("newProjectLanguage").value = "";
document.getElementById("newProjectRepo").value = "";
document.getElementById("newProjectDescription").value = "";
populateProjectTypeDropdowns();
ModalManager.open("addProjectModal");
},
saveNewProject() {
const pid = this.currentDetailProductId;
if (!pid) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const title = document.getElementById("newProjectTitle").value.trim();
const type = document.getElementById("newProjectType").value;
if (!title || !type) {
showToast("عنوان و نوع الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
const np = {
id: `proj-${Date.now()}`,
title,
description:
document.getElementById("newProjectDescription").value.trim() ||
"",
type,
repositoryUrl: document
.getElementById("newProjectRepo")
.value.trim(),
metadata: {
createdAt: new Date().toISOString(),
owner:
document.getElementById("newProjectOwner").value.trim() || "",
customFields: {},
},
dependencies: [],
exploits: [],
deployments: [],
};
const fw = document
.getElementById("newProjectFramework")
.value.trim();
if (fw) np.metadata.framework = fw;
const lg = document.getElementById("newProjectLanguage").value.trim();
if (lg) np.metadata.language = lg;
prod.projects.push(np);
ModalManager.close("addProjectModal");
refreshUI();
this.showDetail(pid);
showToast(`"${title}" ایجاد شد`, "success");
},
openProjectEditModal(productId, projId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
this.currentEditProjectId = projId;
populateProjectTypeDropdowns();
document.getElementById("editProjId").value = pr.id;
document.getElementById("editProjTitle").value = pr.title;
document.getElementById("editProjType").value = pr.type;
document.getElementById("editProjFramework").value =
pr.metadata?.framework || "";
document.getElementById("editProjOwner").value =
pr.metadata?.owner || ""; // 🆕
document.getElementById("editProjLanguage").value =
pr.metadata?.language || "";
document.getElementById("editProjRepo").value =
pr.repositoryUrl || "";
document.getElementById("editProjDescription").value =
pr.description || "";
ModalManager.open("editProjectModal");
},
saveProjectEdit() {
const pid = this.currentDetailProductId;
const projId = this.currentEditProjectId;
if (!pid || !projId) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const nt = document.getElementById("editProjTitle").value.trim();
const nty = document.getElementById("editProjType").value;
if (!nt || !nty) {
showToast("عنوان و نوع الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
pr.title = nt;
pr.type = nty;
pr.description =
document.getElementById("editProjDescription").value.trim() || "";
pr.repositoryUrl = document
.getElementById("editProjRepo")
.value.trim();
if (!pr.metadata) pr.metadata = {};
const fw = document.getElementById("editProjFramework").value.trim();
if (fw) pr.metadata.framework = fw;
else delete pr.metadata.framework;
const owner = document.getElementById("editProjOwner").value.trim();
if (owner) pr.metadata.owner = owner;
else delete pr.metadata.owner;
const lg = document.getElementById("editProjLanguage").value.trim();
if (lg) pr.metadata.language = lg;
else delete pr.metadata.language;
pr.metadata.updatedAt = new Date().toISOString();
if (!pr.exploits) pr.exploits = [];
ModalManager.close("editProjectModal");
refreshUI();
this.showDetail(pid);
showToast(`"${pr.title}" ویرایش شد`, "success");
},
openAddDeploymentModal(productId, projId) {
if (!productId || !projId) return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const allEnvs = projectData.appData.environments;
if (allEnvs.length === 0) {
showToast("ابتدا یک محیط عملیاتی ایجاد کنید", "warning");
return;
}
this.currentDeploymentProjId = projId;
this.currentDeploymentMode = "add";
this.currentDeploymentEnvId = null;
this.currentEditDeploymentId = null;
document.getElementById("deploymentModalTitle").innerHTML =
"<span>🚀</span><span>افزودن نسخه استقرار</span>";
const envGroup = document.getElementById("deploymentEnvGroup");
const envs = allEnvs;
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="required">*</span></label><select class="form-select" id="depEnvironment" onchange="ProductManager.onEnvChange()">${envs
.map((e) => {
const cl = this.getEnvironmentColors(e.id);
return `<option value="${e.id}" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${e.name} - ${e.description}</option>`;
})
.join("")}</select>`;
document.getElementById("depVersion").value = "";
document.getElementById("depDate").value = "";
document.getElementById("depReleaseNotes").value = "";
this.renderRequiredDeploymentsSection();
ModalManager.open("deploymentModal");
},
openEditDeploymentModal(productId, projId, depId) {
if (!productId || !projId || !depId) return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const dep = (pr.deployments || []).find((d) => d.id === depId);
if (!dep) return;
this.currentDeploymentProjId = projId;
this.currentDeploymentMode = "edit";
this.currentDeploymentEnvId = dep.environmentId;
this.currentEditDeploymentId = depId;
document.getElementById("deploymentModalTitle").innerHTML =
"<span>✏️</span><span>ویرایش نسخه استقرار</span>";
const envGroup = document.getElementById("deploymentEnvGroup");
const cl = this.getEnvironmentColors(dep.environmentId);
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="readonly-badge">فقط خواندنی</span></label><div class="env-read-only-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${this.getEnvironmentName(dep.environmentId)}</div>`;
document.getElementById("depVersion").value = dep.version;
let dv = "";
if (dep.deploymentDate) {
const d = new Date(dep.deploymentDate);
const pad = (n) => n.toString().padStart(2, "0");
dv = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
document.getElementById("depDate").value = dv;
document.getElementById("depReleaseNotes").value =
dep.releaseNotes || "";
this.renderRequiredDeploymentsSection(dep);
ModalManager.open("deploymentModal");
},
onEnvChange() {
this.renderRequiredDeploymentsSection();
},
renderRequiredDeploymentsSection(existingDep) {
const section = document.getElementById("requiredDeploymentsSection");
const envSelect = document.getElementById("depEnvironment");
const envId =
this.currentDeploymentMode === "edit"
? this.currentDeploymentEnvId
: envSelect
? envSelect.value
: null;
if (!envId) {
section.innerHTML = "";
return;
}
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDetailProductId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDeploymentProjId,
);
if (!pr) return;
const deps = pr.dependencies || [];
const exploits = pr.exploits || [];
if (deps.length === 0 && exploits.length === 0) {
section.innerHTML = "";
return;
}
let h = '<div class="form-group">';
if (deps.length > 0) {
h += `<label class="form-label">🔗 استقرارهای مورد نیاز (وابستگی‌ها) <span class="required">*</span></label><div class="form-hint">برای هر وابستگی، یک استقرار از آن پروژه در محیط "${this.getEnvironmentName(envId)}" انتخاب کنید</div>`;
deps.forEach((depId) => {
const depPr = this.findProjectById(depId);
if (!depPr) return;
const depDeployments = (depPr.deployments || []).filter(
(d) => d.status === "active" && d.environmentId === envId,
);
const existingReq = existingDep
? (existingDep.requiredDeployments || []).find(
(r) => r.projectId === depId,
)
: null;
h += `<div class="req-deployment-box"><div class="req-deployment-title">📦 ${depPr.title}</div>`;
if (depDeployments.length === 0) {
h += `<div style="color:var(--danger);font-size:11px">⚠️ هیچ استقرار فعالی در ${this.getEnvironmentName(envId)} ندارد.</div>`;
} else {
h += `<select class="req-deployment-select" data-dep-project="${depId}" data-dep-type="dependency">${depDeployments.map((d) => `<option value="${d.id}" ${existingReq && existingReq.deploymentId === d.id ? "selected" : ""}>v${d.version} - ${d.deploymentDate ? new Date(d.deploymentDate).toLocaleDateString("fa-IR") : ""}</option>`).join("")}</select>`;
}
h += "</div>";
});
}
if (exploits.length > 0) {
h += `<label class="form-label" style="margin-top:12px">🔌 سرویس‌های مورد استفاده (اختیاری)</label><div class="form-hint">برای هر سرویس، در صورت تمایل یک استقرار از آن پروژه در محیط "${this.getEnvironmentName(envId)}" انتخاب کنید</div>`;
exploits.forEach((expId) => {
const expPr = this.findProjectById(expId);
if (!expPr) return;
const expDeployments = (expPr.deployments || []).filter(
(d) => d.status === "active" && d.environmentId === envId,
);
const existingReq = existingDep
? (existingDep.requiredExploits || []).find(
(r) => r.projectId === expId,
)
: null;
h += `<div class="req-deployment-box" style="border-color:var(--info)"><div class="req-deployment-title" style="color:var(--info)">🔌 ${expPr.title}</div>`;
if (expDeployments.length === 0) {
h += `<div style="color:var(--text-muted);font-size:11px">ℹ️ هیچ استقرار فعالی در ${this.getEnvironmentName(envId)} ندارد (اختیاری)</div>`;
} else {
h += `<select class="req-deployment-select" data-exploit-project="${expId}" data-dep-type="exploit"><option value="">انتخاب نکنید</option>${expDeployments.map((d) => `<option value="${d.id}" ${existingReq && existingReq.deploymentId === d.id ? "selected" : ""}>v${d.version} - ${d.deploymentDate ? new Date(d.deploymentDate).toLocaleDateString("fa-IR") : ""}</option>`).join("")}</select>`;
}
h += "</div>";
});
}
h += "</div>";
section.innerHTML = h;
},
saveDeployment() {
const pid = this.currentDetailProductId;
const projId = this.currentDeploymentProjId;
if (!pid || !projId) {
showToast("خطا: محصول یا پروژه مشخص نشده", "error");
return;
}
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const version = document.getElementById("depVersion").value.trim();
const date = document.getElementById("depDate").value;
const releaseNotes = document
.getElementById("depReleaseNotes")
.value.trim();
if (!version) {
showToast("نسخه الزامی است", "error");
return;
}
if (!date) {
showToast("تاریخ استقرار الزامی است", "error");
return;
}
if (!releaseNotes) {
showToast("ریلیز نوت الزامی است", "error");
return;
}
const deploymentDate = new Date(date).toISOString();
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (!pr.deployments) pr.deployments = [];
const requiredDeployments = [];
const requiredExploits = [];
document
.querySelectorAll(
'.req-deployment-select[data-dep-type="dependency"]',
)
.forEach((sel) => {
const depProjectId = sel.dataset.depProject;
const depDeploymentId = sel.value;
if (depProjectId && depDeploymentId)
requiredDeployments.push({
projectId: depProjectId,
deploymentId: depDeploymentId,
});
});
document
.querySelectorAll('.req-deployment-select[data-dep-type="exploit"]')
.forEach((sel) => {
const expProjectId = sel.dataset.exploitProject;
const expDeploymentId = sel.value;
if (expProjectId && expDeploymentId)
requiredExploits.push({
projectId: expProjectId,
deploymentId: expDeploymentId,
});
});
if (this.currentDeploymentMode === "add") {
const envId = document.getElementById("depEnvironment").value;
if (!envId) {
showToast("محیط را انتخاب کنید", "error");
return;
}
pr.deployments.push({
id: `dep-${Date.now()}`,
environmentId: envId,
version,
deploymentDate,
releaseNotes,
requiredDeployments,
requiredExploits, // 🆕
status: "active",
});
showToast(
`v${version} در ${this.getEnvironmentName(envId)} استقرار یافت`,
"success",
);
} else {
const depId = this.currentEditDeploymentId;
const dep = pr.deployments.find((d) => d.id === depId);
if (dep) {
dep.version = version;
dep.deploymentDate = deploymentDate;
dep.releaseNotes = releaseNotes;
dep.requiredExploits = requiredExploits;
dep.requiredDeployments = requiredDeployments;
showToast(`استقرار ویرایش شد`, "success");
}
}
ModalManager.close("deploymentModal");
refreshUI();
this.showDetail(pid);
},
removeDeployment(productId, projId, depId) {
if (
!confirm(
"⚠️ حذف کامل این استقرار؟\n\nاین عمل غیرقابل بازگشت است.\nاگر می‌خواهید فقط از لیست فعال خارج شود، از دکمه «آرشیو» استفاده کنید.",
)
)
return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
pr.deployments = (pr.deployments || []).filter((d) => d.id !== depId);
refreshUI();
this.showDetail(productId);
showToast("🗑️ استقرار به‌طور کامل حذف شد", "warning");
},
archiveDeployment(productId, projId, depId) {
if (
!confirm(
"📦 انتقال این استقرار به تاریخچه؟\n\nاستقرار از لیست فعال خارج شده و در بخش تاریخچه نگهداری می‌شود.",
)
)
return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const dep = (pr.deployments || []).find((d) => d.id === depId);
if (!dep) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
dep.status = "archived";
dep.archivedAt = new Date().toISOString();
refreshUI();
this.showDetail(productId);
showToast("📦 استقرار به تاریخچه منتقل شد", "success");
},
removeProduct(productId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
if (!confirm(`حذف "${prod.title}"؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.products = projectData.appData.products.filter(
(p) => p.id !== productId,
);
refreshUI();
ToolbarManager.populateOwnerFilter();
ModalManager.close("projectDetailModal");
showToast(`"${prod.title}" حذف شد`, "warning");
},
removeProject(productId, projId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
if (!confirm(`حذف "${pr.title}"؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
prod.projects = prod.projects.filter((p) => p.id !== projId);
refreshUI();
if (this.currentDetailProductId === productId)
this.showDetail(productId);
showToast(`"${pr.title}" حذف شد`, "warning");
},
toggleArchiveVisibility(projId) {
const list = document.getElementById(`archiveList-${projId}`);
if (!list) return;
const isHidden = list.style.display === "none";
list.style.display = isHidden ? "flex" : "none";
},
restoreDeployment(productId, projId, depId) {
if (!confirm("↩️ بازگرداندن این استقرار به لیست فعال؟")) return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const dep = (pr.deployments || []).find((d) => d.id === depId);
if (!dep) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
// ✅ بازگرداندن به وضعیت active
dep.status = "active";
delete dep.archivedAt;
refreshUI();
this.showDetail(productId);
showToast("↩️ استقرار به لیست فعال بازگردانده شد", "success");
},
openExploitManager(productId, projId) {
this.currentExploitProjectId = productId;
this.currentExploitProjId = projId;
this.renderCurrentExploits();
this.renderAllProjectsForExploits();
this.setupExploitSearch();
ModalManager.open("exploitManagerModal");
},
renderCurrentExploits() {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentExploitProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentExploitProjId,
);
if (!pr) return;
const c = document.getElementById("currentExploitsList");
const exploits = pr.exploits || [];
if (exploits.length === 0) {
c.innerHTML =
'<div class="empty-dependencies">هیچ سرویسی اضافه نشده است</div>';
return;
}
c.innerHTML = exploits
.map((expId, index) => {
const expPr = this.findProjectById(expId);
const projectName = expPr ? expPr.title : expId;
const typeInfo = expPr ? this.getTypeInfo(expPr.type) : null;
return `<div class="dependency-item"><div class="dependency-info"><div><div class="dependency-project-name">${typeInfo ? typeInfo.icon + " " : ""}${projectName}</div></div></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeExploit(${index})">🗑️</button></div>`;
})
.join("");
},
removeExploit(index) {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentExploitProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentExploitProjId,
);
if (!pr) return;
if (!confirm("حذف این سرویس؟")) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
pr.exploits.splice(index, 1);
ModalManager.close("exploitManagerModal");
refreshUI();
if (this.currentDetailProductId)
this.showDetail(this.currentDetailProductId);
showToast("سرویس حذف شد", "success");
},
renderAllProjectsForExploits() {
const rc = document.getElementById("exploitSearchResults");
const cp = projectData.appData.products.find(
(p) => p.id === this.currentExploitProjectId,
);
if (!cp) return;
const cpr = cp.projects.find(
(p) => p.id === this.currentExploitProjId,
);
if (!cpr) return;
const allProjects = [];
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
if (pr.id !== cpr.id)
allProjects.push({ ...pr, productName: prod.title });
});
});
if (allProjects.length === 0) {
rc.innerHTML =
'<div class="empty-dependencies">پروژه دیگری نیست</div>';
return;
}
rc.innerHTML = allProjects
.map((pr) => {
const isExploit = (cpr.exploits || []).includes(pr.id);
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addExploit('${pr.id}')" ${isExploit ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted);font-weight:normal">(${pr.productName})</small>${isExploit ? '<span style="margin-right:auto;font-size:10px;color:var(--success)">✓ استفاده می‌شود</span>' : ""}</button></div>`;
})
.join("");
},
setupExploitSearch() {
const si = document.getElementById("exploitSearch");
si.value = "";
si.oninput = (e) => {
const q = e.target.value.trim().toLowerCase();
if (q.length === 0) {
this.renderAllProjectsForExploits();
return;
}
const cp = projectData.appData.products.find(
(p) => p.id === this.currentExploitProjectId,
);
if (!cp) return;
const cpr = cp.projects.find(
(p) => p.id === this.currentExploitProjId,
);
if (!cpr) return;
const allProjects = [];
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
if (pr.id !== cpr.id)
allProjects.push({ ...pr, productName: prod.title });
});
});
const fl = allProjects.filter(
(pr) =>
pr.title.toLowerCase().includes(q) ||
pr.productName.toLowerCase().includes(q) ||
(pr.description || "").toLowerCase().includes(q),
);
const rc = document.getElementById("exploitSearchResults");
if (fl.length === 0) {
rc.innerHTML =
'<div class="empty-dependencies">نتیجه‌ای نیست</div>';
return;
}
rc.innerHTML = fl
.map((pr) => {
const isExploit = (cpr.exploits || []).includes(pr.id);
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addExploit('${pr.id}')" ${isExploit ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted)">(${pr.productName})</small></button></div>`;
})
.join("");
};
},
addExploit(projectId) {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentExploitProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentExploitProjId,
);
if (!pr) return;
if ((pr.exploits || []).includes(projectId)) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (!pr.exploits) pr.exploits = [];
pr.exploits.push(projectId);
ModalManager.close("exploitManagerModal");
refreshUI();
if (this.currentDetailProductId)
this.showDetail(this.currentDetailProductId);
showToast("سرویس اضافه شد", "success");
},
};
/* ==================== DASHBOARD MANAGER ==================== */
const DashboardManager = {
open() {
ModalManager.open("executiveDashboardModal");
setTimeout(() => this.render(), 150);
},
getReportData() {
const ps = projectData.appData.products || [];
let tp = 0,
td = 0,
tdp = 0;
const tc = {},
edc = {},
ppc = [],
fc = {};
let pwd = 0,
pwod = 0;
ps.forEach((prod) => {
const projects = prod.projects || [];
ppc.push({ title: prod.title, count: projects.length });
projects.forEach((pr) => {
tp++;
td += (pr.dependencies || []).length;
const active = (pr.deployments || []).filter(
(d) => d.status === "active",
);
tdp += active.length;
if (active.length > 0) pwd++;
else pwod++;
tc[pr.type] = (tc[pr.type] || 0) + 1;
const fw = pr.metadata?.framework || "نامشخص";
fc[fw] = (fc[fw] || 0) + 1;
active.forEach((d) => {
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
edc[envName] = (edc[envName] || 0) + 1;
});
});
});
return {
products: ps.length,
projects: tp,
deps: td,
deploys: tdp,
environments: projectData.appData.environments.length,
tc,
edc,
ppc,
fc,
pwd,
pwod,
};
},
render() {
const c = document.getElementById("dashboardContainer");
if (!c) return;
const d = this.getReportData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
h += '</div><div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژه‌ها</div><div class="dashboard-chart-body" id="dashTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="dashEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژه‌ها</div><div class="dashboard-chart-body" id="dashStatusChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژه‌ها به تفکیک محصول</div><div class="dashboard-chart-body" id="dashProductChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🛠️ توزیع فریم‌ورک‌ها</div><div class="dashboard-chart-body" id="dashFrameworkChart"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderDonut(
"dashTypeChart",
Object.entries(d.tc).map(([k, v]) => ({
label: this.getTypeLabel(k),
value: v,
color: this.getTypeColor(k),
})),
);
this.renderBar(
"dashEnvChart",
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
"#38a169",
);
this.renderDonut("dashStatusChart", [
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
]);
this.renderBar(
"dashProductChart",
d.ppc.map((p) => ({
label:
p.title.length > 18
? p.title.substring(0, 18) + "..."
: p.title,
value: p.count,
})),
"#3182ce",
);
this.renderBarH(
"dashFrameworkChart",
Object.entries(d.fc)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([k, v]) => ({
label: k.length > 14 ? k.substring(0, 14) + "..." : k,
value: v,
})),
);
const coverage =
d.pwd + d.pwod > 0
? Math.round((d.pwd / (d.pwd + d.pwod)) * 100)
: 0;
setTimeout(() => {
document
.querySelectorAll("#dashboardContainer .dashboard-chart-body")
.forEach((body) => {
const svg = body.querySelector("svg");
if (svg) {
ChartZoomManager.enable(body, svg);
}
});
}, 200);
document.getElementById("dashboardFooterStats").innerHTML =
`<span>📦 محصولات: <span class="stat-highlight">${d.products}</span></span><span>🌿 پروژه‌ها: <span class="stat-highlight">${d.projects}</span></span><span>🚀 استقرارها: <span class="stat-highlight">${d.deploys}</span></span><span>📈 پوشش استقرار: <span class="stat-highlight">${coverage}%</span></span>`;
},
renderDonut(cid, data) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((dd) => dd.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const total = data.reduce((s, dd) => s + dd.value, 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (dd) => dd.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, dd) {
d3.select(this).transition().duration(150).attr("d", arcH(dd));
showD3Tooltip(
e,
`${dd.data.label}: ${dd.data.value} (${Math.round((dd.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr("fill", "var(--text)")
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(total);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("پروژه");
},
renderBar(cid, data, color) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 45, left: 35 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(data.map((dd) => dd.label))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (dd) => dd.value) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-25)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll(".grid-line")
.data(y.ticks(4))
.join("line")
.attr("x1", 0)
.attr("x2", iw)
.attr("y1", (dd) => y(dd))
.attr("y2", (dd) => y(dd))
.attr("stroke", "var(--border-light)")
.attr("stroke-dasharray", "3,3");
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (dd) => x(dd.label))
.attr("y", (dd) => y(dd.value))
.attr("width", x.bandwidth())
.attr("height", (dd) => ih - y(dd.value))
.attr("fill", color)
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, dd) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", color);
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(data)
.join("text")
.attr("x", (dd) => x(dd.label) + x.bandwidth() / 2)
.attr("y", (dd) => y(dd.value) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((dd) => dd.value);
},
renderBarH(cid, data) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>🛠️</span><div>داده‌ای نیست</div></div>';
return;
}
const bh = 28,
gap = 6;
const w = 320,
hgt = data.length * (bh + gap) + 30;
const m = { top: 10, right: 35, bottom: 10, left: 110 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleLinear()
.domain([0, d3.max(data, (dd) => dd.value) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(data.map((dd) => dd.label))
.range([0, ih])
.padding(0.2);
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", 0)
.attr("y", (dd) => y(dd.label))
.attr("width", (dd) => x(dd.value))
.attr("height", y.bandwidth())
.attr("fill", "#2c5282")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, dd) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#2c5282");
hideD3Tooltip();
});
g.selectAll(".label")
.data(data)
.join("text")
.attr("x", -8)
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((dd) => dd.label);
g.selectAll(".value")
.data(data)
.join("text")
.attr("x", (dd) => x(dd.value) + 5)
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((dd) => dd.value);
},
getTypeLabel(t) {
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
return i ? i.label.split(" ")[0] : t;
},
getTypeColor(t) {
const c = {
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
};
return c[t] || "#718096";
},
};
/* ==================== CHART ZOOM MANAGER ==================== */
const ChartZoomManager = {
_zoomStates: new Map(),
/**
* فعال‌سازی zoom روی یک container
* @param {HTMLElement} container - عنصر dashboard-chart-body
* @param {SVGElement} svg - عنصر SVG داخلی
*/
enable(container, svg) {
if (!container || !svg || container.dataset.zoomEnabled === "true")
return;
container.dataset.zoomEnabled = "true";
const state = {
scale: 1,
translateX: 0,
translateY: 0,
isPanning: false,
};
this._zoomStates.set(container, state);
// 🆕 ایجاد wrapper برای transform
const wrapper = document.createElement("div");
wrapper.className = "chart-zoom-container";
wrapper.style.width = "100%";
wrapper.style.height = "100%";
wrapper.style.display = "flex";
wrapper.style.justifyContent = "center";
wrapper.style.alignItems = "center";
// انتقال SVG به wrapper
while (container.firstChild) {
wrapper.appendChild(container.firstChild);
}
container.appendChild(wrapper);
// 🆕 ایجاد نشانگر zoom
const indicator = document.createElement("div");
indicator.className = "chart-zoom-indicator";
indicator.textContent = "🔍 100%";
container.appendChild(indicator);
// 🆕 ایجاد دکمه reset
const resetBtn = document.createElement("button");
resetBtn.className = "chart-zoom-reset";
resetBtn.textContent = "🎯 بازنشانی";
resetBtn.onclick = (e) => {
e.stopPropagation();
this.resetZoom(container);
};
container.appendChild(resetBtn);
// 🆕 event listener برای wheel (zoom)
container.addEventListener(
"wheel",
(e) => {
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.5, Math.min(5, state.scale * delta));
state.scale = newScale;
this._applyTransform(container, wrapper, state);
indicator.textContent = `🔍 ${Math.round(newScale * 100)}%`;
indicator.classList.add("visible");
resetBtn.classList.toggle("visible", newScale !== 1);
clearTimeout(state._hideTimer);
state._hideTimer = setTimeout(() => {
indicator.classList.remove("visible");
}, 1500);
},
{ passive: false },
);
// 🆕 event listener برای pan (drag)
container.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
if (state.scale <= 1) return; // فقط وقتی zoom شده
state.isPanning = true;
state._startX = e.clientX;
state._startY = e.clientY;
state._startTranslateX = state.translateX;
state._startTranslateY = state.translateY;
wrapper.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (!state.isPanning) return;
const dx = e.clientX - state._startX;
const dy = e.clientY - state._startY;
state.translateX = state._startTranslateX + dx;
state.translateY = state._startTranslateY + dy;
this._applyTransform(container, wrapper, state);
});
window.addEventListener("mouseup", () => {
if (state.isPanning) {
state.isPanning = false;
wrapper.style.cursor = state.scale > 1 ? "grab" : "default";
}
});
// 🆕 dblclick برای reset
container.addEventListener("dblclick", (e) => {
if (e.target === svg || e.target === container) {
this.resetZoom(container);
}
});
},
_applyTransform(container, wrapper, state) {
wrapper.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
},
resetZoom(container) {
const state = this._zoomStates.get(container);
if (!state) return;
state.scale = 1;
state.translateX = 0;
state.translateY = 0;
const wrapper = container.querySelector(".chart-zoom-container");
if (wrapper) {
this._applyTransform(container, wrapper, state);
wrapper.style.cursor = "default";
}
const indicator = container.querySelector(".chart-zoom-indicator");
const resetBtn = container.querySelector(".chart-zoom-reset");
if (indicator) {
indicator.textContent = "🔍 100%";
indicator.classList.add("visible");
setTimeout(() => indicator.classList.remove("visible"), 1500);
}
if (resetBtn) resetBtn.classList.remove("visible");
},
};
/* ==================== GALAXY CHART ==================== */
const GalaxyChartManager = {
svg: null,
zoomBehavior: null,
zoomGroup: null,
nodeElements: null,
linkElements: null,
labelElements: null,
allNodes: [],
collapsedProducts: new Set(),
NODE_COLORS: {
product: "#c9a961",
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
},
open() {
ModalManager.open("galaxyChartModal");
setTimeout(() => this.render(), 150);
},
buildGraphData() {
const nodes = [],
links = [];
const projectToProduct = new Map();
projectData.appData.products.forEach((prod) => {
(prod.projects || []).forEach((pr) => {
projectToProduct.set(pr.id, prod.id);
});
});
projectData.appData.products.forEach((prod) => {
const isCollapsed = this.collapsedProducts.has(prod.id);
nodes.push({
id: prod.id,
label: prod.title,
type: "product",
radius: 30,
data: prod,
collapsed: isCollapsed,
collapsedCount: isCollapsed ? (prod.projects || []).length : 0,
});
if (!isCollapsed) {
(prod.projects || []).forEach((pr) => {
nodes.push({
id: pr.id,
label: pr.title,
type: "project",
projectType: pr.type,
radius: 16,
data: pr,
productTitle: prod.title,
productId: prod.id,
});
links.push({
source: prod.id,
target: pr.id,
type: "ownership",
});
});
}
});
// 🆕 پردازش dependencies
projectData.appData.products.forEach((prod) => {
(prod.projects || []).forEach((pr) => {
(pr.dependencies || []).forEach((depId) => {
let sourceId = pr.id,
targetId = depId;
const sourceProductId = projectToProduct.get(pr.id);
if (
sourceProductId &&
this.collapsedProducts.has(sourceProductId)
)
sourceId = sourceProductId;
const targetProductId = projectToProduct.get(depId);
if (
targetProductId &&
this.collapsedProducts.has(targetProductId)
)
targetId = targetProductId;
if (sourceId === targetId) return;
const exists = links.some((l) => {
const s =
typeof l.source === "object" ? l.source.id : l.source;
const t =
typeof l.target === "object" ? l.target.id : l.target;
return (
s === sourceId && t === targetId && l.type === "dependency"
);
});
if (!exists)
links.push({
source: sourceId,
target: targetId,
type: "dependency",
});
});
// 🆕 پردازش exploits
(pr.exploits || []).forEach((expId) => {
let sourceId = pr.id,
targetId = expId;
const sourceProductId = projectToProduct.get(pr.id);
if (
sourceProductId &&
this.collapsedProducts.has(sourceProductId)
)
sourceId = sourceProductId;
const targetProductId = projectToProduct.get(expId);
if (
targetProductId &&
this.collapsedProducts.has(targetProductId)
)
targetId = targetProductId;
if (sourceId === targetId) return;
const exists = links.some((l) => {
const s =
typeof l.source === "object" ? l.source.id : l.source;
const t =
typeof l.target === "object" ? l.target.id : l.target;
return (
s === sourceId && t === targetId && l.type === "exploit"
);
});
if (!exists)
links.push({
source: sourceId,
target: targetId,
type: "exploit", // 🆕 نوع جدید
});
});
});
});
return { nodes, links };
},
toggleCollapseProduct(productId) {
if (this.collapsedProducts.has(productId))
this.collapsedProducts.delete(productId);
else this.collapsedProducts.add(productId);
this.render();
},
collapseAllProducts() {
projectData.appData.products.forEach((prod) => {
this.collapsedProducts.add(prod.id);
});
this.render();
showToast(
`⊟ ${projectData.appData.products.length} محصول کولاپس شدند`,
"info",
2000,
);
},
expandAllProducts() {
const count = this.collapsedProducts.size;
this.collapsedProducts.clear();
this.render();
showToast(`⊞ ${count} محصول از حالت کولاپس خارج شدند`, "info", 2000);
},
render() {
const c = document.getElementById("galaxyChartContainer");
c.innerHTML = "";
const w = c.clientWidth || 900,
h = c.clientHeight || 600;
const { nodes, links } = this.buildGraphData();
this.allNodes = nodes;
if (nodes.length === 0) {
c.innerHTML =
'<div class="galaxy-empty"><div style="font-size:60px">🌌</div><div>هیچ داده‌ای نیست</div></div>';
return;
}
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", h)
.attr("id", "galaxySvg");
this.svg = svg;
svg
.append("rect")
.attr("width", w)
.attr("height", h)
.attr("fill", "#0a0e27");
for (let i = 0; i < 100; i++) {
svg
.append("circle")
.attr("cx", Math.random() * w)
.attr("cy", Math.random() * h)
.attr("r", Math.random() + 0.3)
.attr("fill", "#fff")
.attr("opacity", Math.random() * 0.5 + 0.1);
}
const defs = svg.append("defs");
const gf = defs.append("filter").attr("id", "galaxyGlow");
gf.append("feGaussianBlur")
.attr("stdDeviation", "3")
.attr("result", "blur");
const fm = gf.append("feMerge");
fm.append("feMergeNode").attr("in", "blur");
fm.append("feMergeNode").attr("in", "SourceGraphic");
defs
.append("marker")
.attr("id", "arrowDependency")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 10) // نقطه مرجع در نوک فلش
.attr("refY", 0)
.attr("markerWidth", 8) // عرض فلش
.attr("markerHeight", 8) // ارتفاع فلش
.attr("orient", "auto")
.attr("markerUnits", "userSpaceOnUse")
.append("path")
.attr("d", "M0,-5L10,0L0,5") // شکل مثلثی فلش
.attr("fill", "#e0c789"); // رنگ طلایی هماهنگ با لینک وابستگی
defs
.append("marker")
.attr("id", "arrowExploit")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 10)
.attr("refY", 0)
.attr("markerWidth", 7)
.attr("markerHeight", 7)
.attr("orient", "auto")
.attr("markerUnits", "userSpaceOnUse")
.append("path")
.attr("d", "M0,-4L10,0L0,4")
.attr("fill", "#4299e1");
// 🆕 Marker برای لینک‌های ownership (اختیاری - بدون فلش یا فلش کوچکتر)
defs
.append("marker")
.attr("id", "arrowOwnership")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 10)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.attr("markerUnits", "userSpaceOnUse")
.append("path")
.attr("d", "M0,-4L10,0L0,4")
.attr("fill", "#4a5568")
.attr("opacity", 0.7);
this.zoomGroup = svg.append("g");
this.zoomBehavior = d3
.zoom()
.scaleExtent([0.2, 5])
.on("zoom", (e) => {
this.zoomGroup.attr("transform", e.transform);
document.getElementById("galaxyZoomIndicator").textContent =
`🔍 ${Math.round(e.transform.k * 100)}%`;
});
svg.call(this.zoomBehavior);
const sim = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(links)
.id((d) => d.id)
.distance((d) => (d.type === "ownership" ? 100 : 160)),
)
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(w / 2, h / 2));
this.linkElements = this.zoomGroup
.append("g")
.selectAll("line")
.data(links)
.join("line")
.attr("stroke", (d) => {
if (d.type === "dependency") return "#e0c789";
if (d.type === "exploit") return "#4299e1"; // 🆕 رنگ آبی
return "#4a5568";
})
.attr("stroke-width", (d) => (d.type === "dependency" ? 2 : 1))
.attr("stroke-opacity", (d) => {
if (d.type === "dependency") return 0.85;
if (d.type === "exploit") return 0.6; // 🆕
return 0.4;
})
.attr("stroke-dasharray", (d) => {
if (d.type === "dependency") return "5,3";
if (d.type === "exploit") return "2,4"; // 🆕 چینش متفاوت
return "none";
})
.attr("marker-end", (d) => {
if (d.type === "dependency") return "url(#arrowDependency)";
if (d.type === "exploit") return "url(#arrowExploit)"; // 🆕
return "url(#arrowOwnership)";
});
this.linkElements
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this)
.attr("stroke-opacity", 1)
.attr("stroke-width", d.type === "dependency" ? 3 : 2);
const sourceNode = this.__data__.source;
const targetNode = this.__data__.target;
const sourceLabel = sourceNode.label || sourceNode.id;
const targetLabel = targetNode.label || targetNode.id;
let tooltipText = "";
if (d.type === "dependency") {
tooltipText = `🔗 ${sourceLabel} → ${targetLabel} (وابستگی)`;
} else if (d.type === "exploit") {
tooltipText = `🔌 ${sourceLabel} → ${targetLabel} (استفاده از سرویس)`;
} else {
tooltipText = `📦 ${targetLabel} شامل ${sourceLabel}`;
}
showD3Tooltip(e, tooltipText);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, d) {
d3.select(this)
.attr("stroke-opacity", d.type === "dependency" ? 0.85 : 0.4)
.attr("stroke-width", d.type === "dependency" ? 2 : 1);
hideD3Tooltip();
});
const node = this.zoomGroup
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.style("cursor", "pointer")
.call(
d3
.drag()
.on("start", (e, d) => {
if (!e.active) sim.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (e, d) => {
d.fx = e.x;
d.fy = e.y;
})
.on("end", (e, d) => {
if (!e.active) sim.alphaTarget(0);
d.fx = null;
d.fy = null;
}),
);
this.nodeElements = node;
node
.append("circle")
.attr("r", (d) => d.radius)
.attr("fill", (d) =>
d.type === "product"
? this.NODE_COLORS.product
: this.NODE_COLORS[d.projectType] || "#718096",
)
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.attr("filter", "url(#galaxyGlow)");
node
.append("text")
.attr("dy", (d) => d.radius + 14)
.attr("text-anchor", "middle")
.attr("fill", "#fff")
.attr("font-size", "10px")
.attr("font-family", "Vazirmatn")
.text((d) => d.label);
node
.filter((d) => d.type === "product" && d.collapsed)
.append("text")
.attr("class", "galaxy-collapse-badge")
.attr("dy", 4)
.attr("text-anchor", "middle")
.attr("fill", "#fff")
.attr("font-size", "11px")
.attr("font-weight", "700")
.text((d) => `+${d.collapsedCount}`);
node.on("click", (e, d) => {
this.showNodeInfo(d);
});
node.on("dblclick", (e, d) => {
if (d.type === "product") {
e.stopPropagation();
this.toggleCollapseProduct(d.id);
}
});
const getEdgePoint = (source, target, targetRadius) => {
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const offset = targetRadius + 10;
return {
x: target.x - (dx / distance) * offset,
y: target.y - (dy / distance) * offset,
};
};
sim.on("tick", () => {
this.linkElements
.attr("x1", (d) => {
// نقطه شروع: روی مرز گره منبع
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
return d.source.x + (dx / distance) * d.source.radius;
})
.attr("y1", (d) => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
return d.source.y + (dy / distance) * d.source.radius;
})
.attr("x2", (d) => {
// نقطه پایان: روی مرز گره هدف (با offset برای فلش)
const edge = getEdgePoint(d.source, d.target, d.target.radius);
return edge.x;
})
.attr("y2", (d) => {
const edge = getEdgePoint(d.source, d.target, d.target.radius);
return edge.y;
});
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
});
this.renderLegend();
this.updateStats(nodes, links);
this.setupSearch();
},
getTypeLabel(t) {
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
return i ? i.label.split(" ")[0] : t;
},
renderLegend() {
const l = document.getElementById("galaxyLegend");
let h = '<div class="galaxy-legend-title">🎨 راهنما</div>';
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS.product}"></div><span>📦 محصول</span></div>`;
VALID_PROJECT_TYPES.forEach((t) => {
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS[t.value]}"></div><span>${t.icon} ${t.label.split(" ")[0]}</span></div>`;
});
h += `<div class="galaxy-legend-item" style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.2)"><div style="width:14px;height:2px;background:#e0c789"></div><span>وابستگی سخت</span></div>`;
h += `<div class="galaxy-legend-item"><div style="width:14px;height:2px;background:#4299e1;border-style:dashed"></div><span>استفاده از سرویس</span></div>`;
l.innerHTML = h;
},
updateStats(nodes, links) {
const p = projectData.appData.products.length;
const pr = projectData.appData.products.reduce(
(x, p) => x + p.projects.length,
0,
);
const dp = links.filter((l) => l.type === "dependency").length;
document.getElementById("galaxyStats").innerHTML =
`<span>📦 ${p}</span><span>🌿 ${pr}</span><span>🔗 ${dp}</span>`;
},
showNodeInfo(d) {
const p = document.getElementById("galaxyInfoPanel");
const ic = document.getElementById("galaxyInfoContent");
let ih = "";
if (d.type === "product") {
const projects = d.data.projects || [];
const isCollapsed = this.collapsedProducts.has(d.id);
ih += `<h4>📦 ${d.label}</h4>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">توضیحات</span><span class="galaxy-info-value">${d.data.description || "—"}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">مالک</span><span class="galaxy-info-value">${d.data.metadata?.owner || "—"}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">تعداد پروژه</span><span class="galaxy-info-value">${projects.length}</span></div>`;
if (projects.length > 0) {
ih += `<div style="margin-top:10px;border-top:1px solid rgba(255,255,255,.1);padding-top:8px"><div style="font-weight:700;font-size:11px;margin-bottom:6px">🌿 پروژه‌ها:</div>`;
projects.forEach((pr) => {
const ti = this.getTypeInfo(pr.type);
ih += `<div style="font-size:11px;padding:3px 0;color:rgba(255,255,255,.8)">${ti.icon} ${pr.title}</div>`;
});
ih += `</div>`;
}
} else {
const ti = this.getTypeInfo(d.projectType);
const deploys = ProductManager.getActiveDeployments(d.data);
ih += `<h4>${ti.icon} ${d.label}</h4>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">نوع</span><span class="galaxy-info-value">${ti.label}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">فریم‌ورک</span><span class="galaxy-info-value">${d.data.metadata?.framework || "—"}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">استقرارها</span><span class="galaxy-info-value">${deploys.length}</span></div>`;
}
ic.innerHTML = ih;
p.classList.add("visible");
},
getTypeInfo(type) {
return (
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
value: type,
label: type,
icon: "❓",
}
);
},
setupSearch() {
const inp = document.getElementById("galaxySearchInput");
const clr = document.getElementById("galaxySearchClear");
if (!inp || !clr) return;
inp.value = "";
clr.classList.remove("visible");
inp.oninput = () => this.applySearch(inp.value.trim());
inp.onkeydown = (e) => {
if (e.key === "Escape" && inp.value) {
e.stopPropagation();
this.clearSearch();
inp.focus();
}
};
clr.onclick = () => {
this.clearSearch();
inp.focus();
};
},
applySearch(query) {
const clr = document.getElementById("galaxySearchClear");
const results = document.getElementById("galaxySearchResults");
if (clr) clr.classList.toggle("visible", !!query);
if (!query) {
this.clearSearch();
return;
}
const q = query.toLowerCase();
const matches = this.allNodes.filter((n) => {
const hay = [
n.label,
n.productTitle || "",
n.data?.description || "",
n.data?.metadata?.framework || "",
n.data?.metadata?.language || "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
});
const matchIds = new Set(matches.map((m) => m.id));
if (this.nodeElements) {
this.nodeElements
.classed("galaxy-dim", (d) => !matchIds.has(d.id))
.classed("galaxy-match", (d) => matchIds.has(d.id));
}
if (this.linkElements) {
this.linkElements.classed("galaxy-dim", (l) => {
const s = typeof l.source === "object" ? l.source.id : l.source;
const t = typeof l.target === "object" ? l.target.id : l.target;
return !(matchIds.has(s) || matchIds.has(t));
});
}
if (!results) return;
if (matches.length === 0) {
results.innerHTML =
'<div class="galaxy-search-empty">نتیجه‌ای یافت نشد</div>';
} else {
results.innerHTML = matches
.slice(0, 30)
.map((n) => {
const icon =
n.type === "product"
? "📦"
: ProductManager.getTypeInfo(n.projectType)?.icon || "❓";
const sub =
n.type === "product"
? "محصول"
: ProductManager.getTypeInfo(n.projectType)?.label || "";
return `<div class="galaxy-search-result-item" data-node-id="${n.id}"><span class="galaxy-search-result-icon">${icon}</span><div class="galaxy-search-result-text"><div class="galaxy-search-result-title">${n.label}</div><div class="galaxy-search-result-sub">${sub}</div></div></div>`;
})
.join("");
results
.querySelectorAll(".galaxy-search-result-item")
.forEach((item) => {
item.addEventListener("click", () => {
this.focusNode(item.dataset.nodeId);
results.classList.remove("visible");
});
});
}
results.classList.add("visible");
},
clearSearch() {
const inp = document.getElementById("galaxySearchInput");
const clr = document.getElementById("galaxySearchClear");
const results = document.getElementById("galaxySearchResults");
if (inp) inp.value = "";
if (clr) clr.classList.remove("visible");
if (results) {
results.innerHTML = "";
results.classList.remove("visible");
}
if (this.nodeElements)
this.nodeElements
.classed("galaxy-dim", false)
.classed("galaxy-match", false)
.classed("galaxy-focus", false);
if (this.linkElements) this.linkElements.classed("galaxy-dim", false);
},
focusNode(nodeId) {
const n = this.allNodes.find((x) => x.id === nodeId);
if (!n || !this.svg || !this.zoomBehavior) return;
const c = document.getElementById("galaxyChartContainer");
const w = c.clientWidth || 900,
h = c.clientHeight || 600;
const scale = 1.6;
const t = d3.zoomIdentity
.translate(w / 2 - scale * (n.x || 0), h / 2 - scale * (n.y || 0))
.scale(scale);
this.svg
.transition()
.duration(600)
.call(this.zoomBehavior.transform, t);
this.showNodeInfo(n);
if (this.nodeElements) {
this.nodeElements.classed("galaxy-focus", (d) => d.id === nodeId);
setTimeout(() => {
if (this.nodeElements)
this.nodeElements.classed("galaxy-focus", false);
}, 2000);
}
},
exportPNG() {
const svg = this.svg;
if (!svg) {
showToast("❌ نموداری برای خروجی وجود ندارد", "error");
return;
}
try {
// 🆕 کپی از SVG اصلی
const svgNode = svg.node();
const clone = svgNode.cloneNode(true);
// 🆕 تنظیم ابعاد
const width = svgNode.clientWidth || 1200;
const height = svgNode.clientHeight || 800;
clone.setAttribute("width", width);
clone.setAttribute("height", height);
clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
// 🆕 اضافه کردن xmlns (الزامی برای تبدیل به تصویر)
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
// 🆕 تبدیل style های inline به attributes (برای سازگاری)
clone.querySelectorAll("*").forEach((el) => {
const computedStyle = window.getComputedStyle(
svgNode.querySelector(
`[class="${el.getAttribute("class")}"]`,
) || el,
);
// حفظ font-family برای متن‌ها
if (el.tagName === "text") {
el.setAttribute("font-family", "Vazirmatn, Tahoma, sans-serif");
}
});
// 🆕 serialize به string
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(clone);
// 🆕 اضافه کردن declaration
svgString = '<?xml version="1.0" standalone="no"?>\r\n' + svgString;
// 🆕 تبدیل به data URL
const svgBlob = new Blob([svgString], {
type: "image/svg+xml;charset=utf-8",
});
const url = URL.createObjectURL(svgBlob);
// 🆕 ایجاد Image و تبدیل به Canvas
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width * 2; // 🆕 کیفیت 2x برای وضوح بیشتر
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
ctx.scale(2, 2);
ctx.fillStyle = "#0a0e27"; // 🆕 پس‌زمینه تیره نمودار کهکشانی
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
// 🆕 دانلود فایل
canvas.toBlob(
(blob) => {
if (!blob) {
showToast("❌ خطا در تولید تصویر", "error");
return;
}
const pngUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = pngUrl;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
a.download = `x-release-assistant-chart-${timestamp}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(pngUrl);
URL.revokeObjectURL(url);
showToast("📷 خروجی PNG با موفقیت ذخیره شد", "success");
},
"image/png",
1.0,
);
};
img.onerror = () => {
showToast("❌ خطا در بارگذاری SVG", "error");
URL.revokeObjectURL(url);
};
img.src = url;
} catch (err) {
showToast("❌ خطا: " + err.message, "error");
}
},
};
/* ==================== AUTOSAVE MANAGER ==================== */
const AutosaveManager = {
STORAGE_KEY: "xrelease-appdata-autosave",
saveTimer: null,
lastSaveTime: null,
DEBOUNCE_MS: 1500,
scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), this.DEBOUNCE_MS);
},
save() {
try {
const data = {
version: projectData.version || "1.2.0",
appData: JSON.parse(JSON.stringify(projectData.appData)),
activeBoardId: ReleaseBoardsManager?.activeBoardId || null,
savedAt: new Date().toISOString(),
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
this.lastSaveTime = new Date();
this.showIndicator();
} catch (e) {
console.error("Autosave failed:", e);
}
},
load() {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return null;
try {
return JSON.parse(saved);
} catch (e) {
return null;
}
},
clear() {
localStorage.removeItem(this.STORAGE_KEY);
},
showIndicator() {
const indicator = document.getElementById("rbAutosaveIndicator");
const text = document.getElementById("rbAutosaveText");
if (!indicator || !text) return;
text.textContent = "✓ ذخیره خودکار انجام شد";
indicator.style.opacity = "1";
setTimeout(() => {
indicator.style.opacity = "0";
}, 2000);
},
checkRecovery() {
const saved = this.load();
if (!saved || !saved.appData) return;
document.getElementById("rbRecoverAutosaveBtn").onclick = () =>
this.recover();
document.getElementById("rbDiscardAutosaveBtn").onclick = () =>
this.discard();
setTimeout(() => {
ModalManager.open("rbAutosaveModal");
}, 600);
},
recover() {
const saved = this.load();
if (!saved || !saved.appData) {
ModalManager.close("rbAutosaveModal");
return;
}
HistoryManager.pushState();
projectData.appData = saved.appData;
if (saved.activeBoardId && ReleaseBoardsManager)
ReleaseBoardsManager.activeBoardId = saved.activeBoardId;
this.clear();
ModalManager.close("rbAutosaveModal");
updateProjectUI();
renderToolGroups();
ToolbarManager.populateEnvironmentFilter();
ToolbarManager.populateOwnerFilter();
refreshUI();
if (ReleaseBoardsManager) {
ReleaseBoardsManager.render(true);
ReleaseBoardsManager.updateUndoRedoButtons();
}
showToast("✅ داده‌های خودکار با موفقیت بازیابی شدند", "success");
},
discard() {
this.clear();
ModalManager.close("rbAutosaveModal");
showToast("🗑️ داده‌های خودکار نادیده گرفته شدند", "info");
},
};
/* ==================== RELEASE BOARDS MANAGER ==================== */
const ReleaseBoardsManager = {
activeBoardId: null,
editing: {
boardId: null,
columnId: null,
cardId: null,
columnForCard: null,
},
tempData: {},
_tabsCache: "",
_dateCache: new Map(),
_eventsBound: false,
_lastUndoRedoState: { undo: -1, redo: -1 },
_renderCache: { boardId: null, columnsHash: "" },
filters: { search: "", tags: [] },
dragType: null,
dragCardId: null,
dragColumnId: null,
DEFAULT_COLUMNS: [
{
title: "📥 بک لاگ",
backgroundColor: "#6b7208",
textColor: "#b4cbf9",
},
{
title: "📋 برای انجام",
backgroundColor: "#393e65",
textColor: "#b4cbf9",
},
{
title: "🔨 در حال انجام",
backgroundColor: "#453111",
textColor: "#b4cbf9",
},
{
title: "🔍 برای تست",
backgroundColor: "#542881",
textColor: "#b4cbf9",
},
{
title: "✅ انجام شده",
backgroundColor: "#087224",
textColor: "#d8d96d",
},
],
open() {
ModalManager.open("releaseBoardsModal");
setTimeout(() => {
const boards = projectData.appData.boards || [];
if (boards.length > 0 && !this.activeBoardId)
this.activeBoardId = boards[0].id;
this.render(true);
this.bindFormEvents();
this.renderFilterBar();
this.bindSearchEvents();
this.bindToolbarEvents();
this.updateUndoRedoButtons();
}, 150);
},
render(forceFull = false) {
this.renderTabs();
const board = this.getActiveBoard();
const boardArea = document.getElementById("rbBoardArea");
if (!board) {
document.getElementById("rbEmptyState").style.display = "flex";
document.getElementById("rbBoardWrapper").style.display = "none";
if (boardArea) boardArea.style.background = "";
this._renderCache.boardId = null;
this._renderCache.columnsHash = "";
this.renderFilterBar();
return;
}
const columnsHash = board.columns
.map(
(c) =>
c.id +
":" +
c.cards.length +
":" +
c.cards.map((k) => k.id).join(","),
)
.join("|");
const needsFullRender =
forceFull ||
this._renderCache.boardId !== board.id ||
this._renderCache.columnsHash !== columnsHash;
if (needsFullRender) {
this._fullRenderBoard(board);
this._renderCache.boardId = board.id;
this._renderCache.columnsHash = columnsHash;
} else {
this._incrementalRender(board);
}
this.applyFilters();
this.initDragAndDrop();
this.updateFooterStats();
},
_fullRenderBoard(board) {
const emptyState = document.getElementById("rbEmptyState");
const wrapper = document.getElementById("rbBoardWrapper");
const boardArea = document.getElementById("rbBoardArea");
emptyState.style.display = "none";
wrapper.style.display = "flex";
boardArea.style.background = board.backgroundColor || "";
const fragment = document.createDocumentFragment();
board.columns.forEach((col) => {
const colEl = this._createColumnElement(col);
fragment.appendChild(colEl);
});
const addBtn = document.createElement("button");
addBtn.className = "add-column-btn";
addBtn.innerHTML = "➕ افزودن ستون جدید";
addBtn.onclick = () => this.openNewColumnModal();
fragment.appendChild(addBtn);
wrapper.innerHTML = "";
wrapper.appendChild(fragment);
},
_incrementalRender(board) {
const wrapper = document.getElementById("rbBoardWrapper");
if (!wrapper) return;
board.columns.forEach((col) => {
const colBody = wrapper.querySelector(
`[data-column-id="${col.id}"]`,
);
if (!colBody) return;
const countEl = colBody
.closest(".column")
.querySelector(".column-count");
if (countEl) countEl.textContent = col.cards.length;
const existingCards = new Map();
colBody.querySelectorAll(".card").forEach((el) => {
existingCards.set(el.dataset.cardId, el);
});
col.cards.forEach((card) => {
if (existingCards.has(card.id)) {
const el = existingCards.get(card.id);
this._updateCardElement(el, card);
existingCards.delete(card.id);
} else {
const newEl = this._createCardElement(card);
colBody.appendChild(newEl);
}
});
existingCards.forEach((el) => el.remove());
});
},
_createColumnElement(col) {
const div = document.createElement("div");
div.className = "column";
div.dataset.columnId = col.id;
if (col.backgroundColor) div.style.background = col.backgroundColor;
if (col.textColor) div.style.color = col.textColor;
div.innerHTML = this.renderColumn(col);
return div.firstElementChild;
},
_createCardElement(card) {
const div = document.createElement("div");
div.innerHTML = this.renderCard(card);
return div.firstElementChild;
},
_updateCardElement(el, card) {
const newHtml = this.renderCard(card);
if (el.innerHTML !== newHtml) el.innerHTML = newHtml;
},
_getFormattedDate(isoString) {
if (!isoString) return "";
if (this._dateCache.has(isoString))
return this._dateCache.get(isoString);
const formatted = new Date(isoString).toLocaleDateString("fa-IR");
this._dateCache.set(isoString, formatted);
if (this._dateCache.size > 500) {
const firstKey = this._dateCache.keys().next().value;
this._dateCache.delete(firstKey);
}
return formatted;
},
_countTagUsage(tagId) {
let count = 0;
const boards = projectData.appData.boards || [];
for (const board of boards) {
for (const col of board.columns) {
for (const card of col.cards) {
if ((card.tags || []).includes(tagId)) count++;
}
}
}
return count;
},
renderTabs() {
const tabsBar = document.getElementById("rbTabsBar");
if (!tabsBar) return;
const boards = projectData.appData.boards || [];
const hash =
boards.map((b) => b.id + ":" + b.title).join("|") +
"|" +
this.activeBoardId;
if (hash === this._tabsCache) return;
this._tabsCache = hash;
if (boards.length === 0) {
tabsBar.innerHTML =
'<div class="rb-empty-tabs">هیچ بردی ایجاد نشده است</div>';
return;
}
tabsBar.innerHTML =
boards
.map(
(board) =>
`<button class="rb-tab-item ${board.id === this.activeBoardId ? "active" : ""}" data-board-id="${board.id}"><span>📋</span><span>${this.escapeHtml(board.title)}</span><button class="rb-tab-close" data-delete-board="${board.id}" title="حذف برد">✕</button></button>`,
)
.join("") +
`<button class="rb-tab-add" onclick="ReleaseBoardsManager.openNewBoardModal()" title="ایجاد برد جدید">➕</button>`;
tabsBar.querySelectorAll(".rb-tab-item").forEach((tab) => {
tab.addEventListener("click", (e) => {
if (e.target.closest(".rb-tab-close")) return;
this.activeBoardId = tab.dataset.boardId;
this.render(true);
});
});
tabsBar.querySelectorAll(".rb-tab-close").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
this.deleteBoard(btn.dataset.deleteBoard);
});
});
},
renderColumn(col) {
const cardCount = col.cards.length;
const style = [];
if (col.backgroundColor)
style.push(`background:${col.backgroundColor};`);
if (col.textColor) style.push(`color:${col.textColor};`);
return `
<div class="column" data-column-id="${col.id}" style="${style.join(" ")}">
<div class="column-header" draggable="true">
<span class="drag-handle-hint" title="بکشید تا ستون جابجا شود">⠿</span>
<div class="column-title"><span>${this.escapeHtml(col.title)}</span><span class="column-count">${cardCount}</span></div>
<div class="column-actions">
<button class="column-action-btn" onclick="ReleaseBoardsManager.openEditColumnModal('${col.id}')" title="ویرایش">✏️</button>
<button class="column-action-btn" onclick="ReleaseBoardsManager.deleteColumn('${col.id}')" title="حذف">🗑️</button>
</div>
</div>
<div class="column-body" data-column-id="${col.id}">
${col.cards.length === 0 ? `<div class="empty-state"><div style="font-size:30px;opacity:0.3;">📭</div><div style="font-size:11px;margin-top:6px;">کارتی وجود ندارد</div></div>` : col.cards.map((card) => this.renderCard(card)).join("")}
<div class="column-filter-empty">🔍 کارتی با فیلترهای فعلی یافت نشد</div>
</div>
<div class="column-footer">
<button class="add-card-btn" onclick="ReleaseBoardsManager.openNewCardModal('${col.id}')">➕ افزودن کارت</button>
</div>
</div>`;
},
renderCard(card) {
const tags = (card.tags || [])
.map((tid) => {
const tag = (projectData.appData.boardTags || []).find(
(t) => t.id === tid,
);
return tag
? `<span class="card-tag" style="background:${tag.color};">${this.escapeHtml(tag.name)}</span>`
: "";
})
.join("");
const checklistTotal = (card.checklist || []).length;
const checklistDone = (card.checklist || []).filter(
(i) => i.done,
).length;
const checklistHtml =
checklistTotal > 0
? `<div class="card-checklist-progress" title="${checklistDone}/${checklistTotal}"><div class="card-checklist-bar" style="width:${(checklistDone / checklistTotal) * 100}%;"></div></div>`
: "";
let dateClass = "",
dateText = "";
if (card.estimatedDate) {
dateText = this._getFormattedDate(card.estimatedDate);
const due = new Date(card.estimatedDate);
const diff = (due - Date.now()) / (1000 * 60 * 60 * 24);
if (diff < 0) dateClass = "overdue";
else if (diff <= 3) dateClass = "soon";
}
const assigneesHtml =
(card.assignees || []).length > 0
? `<div class="card-assignees">${card.assignees.map((a) => `<span class="card-assignee-badge">👤 ${this.escapeHtml(a.name)}</span>`).join("")}</div>`
: "";
const depsHtml =
(card.dependencies || []).length > 0
? `<div class="card-deps">${card.dependencies
.map((depId) => {
const depCard = this.findCardById(depId);
return depCard
? `<span class="card-dep-badge">🔗 ${this.escapeHtml(depCard.title)}</span>`
: "";
})
.join("")}</div>`
: "";
const bgStyle = card.backgroundColor
? `background:${card.backgroundColor};`
: "";
return `
<div class="card" data-card-id="${card.id}" draggable="true" onclick="ReleaseBoardsManager.openEditCardModal('${card.id}')" style="${bgStyle}">
${tags ? `<div class="card-tags">${tags}</div>` : ""}
<div class="card-title">${this.escapeHtml(card.title)}</div>
${card.description ? `<div class="card-desc">${this.escapeHtml(card.description)}</div>` : ""}
${assigneesHtml}
${depsHtml}
<div class="card-meta">
${card.estimatedDate ? `<span class="card-meta-item ${dateClass}">📅 ${dateText}</span>` : ""}
${checklistTotal > 0 ? `<span class="card-meta-item">✅ ${checklistDone}/${checklistTotal}</span>` : ""}
</div>
${checklistHtml}
</div>`;
},
renderFilterBar() {
const container = document.getElementById("rbTagFilterContainer");
const clearBtn = document.getElementById("rbClearTagFilter");
if (!container) return;
const tags = projectData.appData.boardTags || [];
if (tags.length === 0) {
container.innerHTML =
'<span class="rb-filter-empty">تگی برای فیلتر وجود ندارد — از بخش «تگ‌ها» ایجاد کنید</span>';
if (clearBtn) {
clearBtn.disabled = true;
}
return;
}
container.innerHTML = tags
.map((tag) => {
const active = this.filters.tags.includes(tag.id);
return `<button class="rb-filter-chip ${active ? "active" : ""}" style="--chip-color:${tag.color}" data-tag-id="${tag.id}" title="${active ? "کلیک برای حذف از فیلتر" : "کلیک برای افزودن به فیلتر"}"><span class="chip-dot" style="background:${tag.color}"></span>${this.escapeHtml(tag.name)}</button>`;
})
.join("");
container.querySelectorAll(".rb-filter-chip").forEach((chip) => {
chip.addEventListener("click", () => {
const tid = chip.dataset.tagId;
const idx = this.filters.tags.indexOf(tid);
if (idx >= 0) {
this.filters.tags.splice(idx, 1);
showToast("🏷️ فیلتر تگ حذف شد", "info", 1500);
} else {
this.filters.tags.push(tid);
}
this.renderFilterBar();
this.applyFilters();
});
});
if (clearBtn) {
// ✅ اصلاح: دکمه حذف فیلتر همیشه نمایش داده شود
// فقط وقتی فیلتر فعال نیست، غیرفعال باشد
clearBtn.disabled = this.filters.tags.length === 0;
clearBtn.onclick = () => {
if (this.filters.tags.length === 0) return;
this.filters.tags = [];
this.renderFilterBar();
this.applyFilters();
showToast("🏷️ تمام فیلترهای تگ پاک شدند", "info");
};
}
},
applyFilters() {
const board = this.getActiveBoard();
if (!board) return;
const wrapper = document.getElementById("rbBoardWrapper");
if (!wrapper) return;
const searchQ = this.filters.search.trim().toLowerCase();
const activeTags = this.filters.tags;
const hasFilter = searchQ || activeTags.length > 0;
let visibleCount = 0,
totalCount = 0;
wrapper.querySelectorAll(".card").forEach((cardEl) => {
const cardId = cardEl.dataset.cardId;
let card = null;
for (const col of board.columns) {
const c = col.cards.find((x) => x.id === cardId);
if (c) {
card = c;
break;
}
}
if (!card) return;
totalCount++;
let matchSearch = true;
if (searchQ) {
const tagNames = (card.tags || [])
.map((tid) => {
const t = (projectData.appData.boardTags || []).find(
(x) => x.id === tid,
);
return t ? t.name : "";
})
.join(" ");
const hay = (
card.title +
" " +
(card.description || "") +
" " +
tagNames
).toLowerCase();
matchSearch = hay.includes(searchQ);
}
let matchTags = true;
if (activeTags.length > 0) {
matchTags = activeTags.some((tid) =>
(card.tags || []).includes(tid),
);
}
if (matchSearch && matchTags) {
cardEl.classList.remove("hidden-by-filter");
visibleCount++;
} else {
cardEl.classList.add("hidden-by-filter");
}
});
/* نمایش پیام خالی در ستون‌ها هنگام فیلتر */
wrapper.querySelectorAll(".column").forEach((col) => {
const body = col.querySelector(".column-body");
if (!body) return;
const visibleCards = body.querySelectorAll(
".card:not(.hidden-by-filter)",
).length;
const totalCards = body.querySelectorAll(".card").length;
const emptyMsg = body.querySelector(".column-filter-empty");
if (emptyMsg) {
if (hasFilter && totalCards > 0 && visibleCards === 0)
emptyMsg.classList.add("visible");
else emptyMsg.classList.remove("visible");
}
});
/* به‌روزرسانی وضعیت فیلتر */
const statusEl = document.getElementById("rbFilterStatus");
const countEl = document.getElementById("rbFilterCount");
const totalEl = document.getElementById("rbFilterTotal");
if (statusEl && countEl && totalEl) {
if (hasFilter) {
statusEl.classList.add("visible");
countEl.textContent = visibleCount.toLocaleString("fa-IR");
totalEl.textContent = totalCount.toLocaleString("fa-IR");
} else {
statusEl.classList.remove("visible");
}
}
},
bindSearchEvents() {
const inp = document.getElementById("rbCardSearchInput");
const clr = document.getElementById("rbCardSearchClear");
if (!inp || !clr) return;
inp.oninput = () => {
this.filters.search = inp.value;
clr.classList.toggle("visible", !!inp.value);
this.applyFilters();
};
inp.onkeydown = (e) => {
if (e.key === "Escape" && inp.value) {
inp.value = "";
this.filters.search = "";
clr.classList.remove("visible");
this.applyFilters();
inp.focus();
}
};
clr.onclick = () => {
inp.value = "";
this.filters.search = "";
clr.classList.remove("visible");
this.applyFilters();
inp.focus();
};
},
initDragAndDrop() {
const wrapper = document.getElementById("rbBoardWrapper");
if (!wrapper) return;
/* ── درگ کارت‌ها ── */
wrapper.querySelectorAll(".card").forEach((card) => {
card.addEventListener("dragstart", (e) => {
e.stopPropagation();
card.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", card.dataset.cardId);
this.dragType = "card";
this.dragCardId = card.dataset.cardId;
});
card.addEventListener("dragend", () => {
card.classList.remove("dragging");
this.clearDropIndicators();
this.dragType = null;
this.dragCardId = null;
});
});
/* ── درگ ستون‌ها ── */
wrapper.querySelectorAll(".column").forEach((col) => {
const header = col.querySelector(".column-header");
if (header) {
header.addEventListener("dragstart", (e) => {
e.stopPropagation();
col.classList.add("dragging-column");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", "");
this.dragType = "column";
this.dragColumnId = col.dataset.columnId;
});
header.addEventListener("dragend", () => {
col.classList.remove("dragging-column");
this.clearDropIndicators();
this.dragType = null;
this.dragColumnId = null;
});
}
/* محل رها کردن کارت */
const body = col.querySelector(".column-body");
if (body) {
body.addEventListener("dragover", (e) => {
if (this.dragType === "card") {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
col.classList.add("drag-over");
}
});
body.addEventListener("dragleave", (e) => {
if (!col.contains(e.relatedTarget))
col.classList.remove("drag-over");
});
body.addEventListener("drop", (e) => {
e.preventDefault();
col.classList.remove("drag-over");
if (this.dragType === "card" && this.dragCardId) {
this.moveCard(this.dragCardId, col.dataset.columnId);
}
});
}
/* محل رها کردن ستون */
col.addEventListener("dragover", (e) => {
if (
this.dragType === "column" &&
this.dragColumnId !== col.dataset.columnId
) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
col.classList.add("drag-over-column");
}
});
col.addEventListener("dragleave", (e) => {
if (!col.contains(e.relatedTarget))
col.classList.remove("drag-over-column");
});
col.addEventListener("drop", (e) => {
if (this.dragType === "column" && this.dragColumnId) {
e.preventDefault();
e.stopPropagation();
col.classList.remove("drag-over-column");
this.moveColumn(this.dragColumnId, col.dataset.columnId);
}
});
});
},
clearDropIndicators() {
document
.querySelectorAll(".drag-over")
.forEach((el) => el.classList.remove("drag-over"));
document
.querySelectorAll(".drag-over-column")
.forEach((el) => el.classList.remove("drag-over-column"));
},
moveCard(cardId, targetColumnId) {
const board = this.getActiveBoard();
if (!board) return;
let sourceCol = null,
card = null,
sourceIdx = -1;
for (const col of board.columns) {
const idx = col.cards.findIndex((c) => c.id === cardId);
if (idx >= 0) {
sourceCol = col;
card = col.cards[idx];
sourceIdx = idx;
break;
}
}
if (!card || !sourceCol) return;
if (sourceCol.id === targetColumnId) return;
const targetCol = board.columns.find((c) => c.id === targetColumnId);
if (!targetCol) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
sourceCol.cards.splice(sourceIdx, 1);
targetCol.cards.push(card);
if (!card.history) card.history = [];
card.history.push({
type: "move",
title: `جابجایی به "${targetCol.title}"`,
from: sourceCol.title,
to: targetCol.title,
date: new Date().toISOString(),
});
this.render(true);
showToast(
`📋 کارت به "${targetCol.title}" منتقل شد`,
"success",
2000,
);
},
moveColumn(sourceColId, targetColId) {
const board = this.getActiveBoard();
if (!board) return;
if (sourceColId === targetColId) return;
const sourceIdx = board.columns.findIndex(
(c) => c.id === sourceColId,
);
const targetIdx = board.columns.findIndex(
(c) => c.id === targetColId,
);
if (sourceIdx < 0 || targetIdx < 0) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
const [moved] = board.columns.splice(sourceIdx, 1);
board.columns.splice(targetIdx, 0, moved);
this.render(true);
showToast("📊 ستون جابجا شد", "success", 2000);
},
getActiveBoard() {
return (projectData.appData.boards || []).find(
(b) => b.id === this.activeBoardId,
);
},
openNewBoardModal() {
this.editing.boardId = null;
document.getElementById("rbBoardFormTitle").textContent =
"ایجاد برد جدید";
document.getElementById("rbBoardTitle").value = "";
document.getElementById("rbBoardDesc").value = "";
document.getElementById("rbBoardBgColor").value = "#e2e8f0";
const defaultGroup = document.getElementById("rbDefaultColumnsGroup");
if (defaultGroup) defaultGroup.style.display = "block";
const useDefault = document.getElementById("rbUseDefaultColumns");
if (useDefault) useDefault.checked = true;
const preview = document.getElementById("rbDefaultColumnsPreview");
if (preview) preview.classList.remove("hidden");
if (useDefault) {
useDefault.onchange = () => {
const preview = document.getElementById(
"rbDefaultColumnsPreview",
);
if (preview)
preview.classList.toggle("hidden", !useDefault.checked);
};
}
ModalManager.open("rbBoardFormModal");
},
openEditBoardModal() {
const board = this.getActiveBoard();
if (!board) {
showToast("بردی برای ویرایش وجود ندارد", "warning");
return;
}
this.editing.boardId = board.id;
document.getElementById("rbBoardFormTitle").textContent =
"ویرایش برد";
document.getElementById("rbBoardTitle").value = board.title;
document.getElementById("rbBoardDesc").value =
board.description || "";
document.getElementById("rbBoardBgColor").value =
board.backgroundColor || "#e2e8f0";
const defaultGroup = document.getElementById("rbDefaultColumnsGroup");
if (defaultGroup) defaultGroup.style.display = "none";
ModalManager.open("rbBoardFormModal");
},
saveBoard() {
const title = document.getElementById("rbBoardTitle").value.trim();
if (!title) {
showToast("عنوان الزامی است", "error");
return;
}
const desc = document.getElementById("rbBoardDesc").value.trim();
const bgColor = document.getElementById("rbBoardBgColor").value;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (!projectData.appData.boards) projectData.appData.boards = [];
if (this.editing.boardId) {
const board = projectData.appData.boards.find(
(b) => b.id === this.editing.boardId,
);
if (board) {
board.title = title;
board.description = desc;
board.backgroundColor = bgColor;
board.updatedAt = new Date().toISOString();
showToast("برد بروزرسانی شد", "success");
}
} else {
const useDefault = document.getElementById(
"rbUseDefaultColumns",
)?.checked;
const newBoard = {
id: `board-${Date.now()}`,
title,
description: desc,
backgroundColor: bgColor,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
columns: [],
};
if (useDefault) {
newBoard.columns = this.DEFAULT_COLUMNS.map((col, idx) => ({
id: `col-${Date.now()}-${idx}`,
title: col.title,
backgroundColor: col.backgroundColor,
textColor: col.textColor,
cards: [],
}));
}
projectData.appData.boards.push(newBoard);
this.activeBoardId = newBoard.id;
if (useDefault)
showToast(`برد "${title}" با ۵ ستون پیش‌فرض ایجاد شد`, "success");
else showToast(`برد خالی "${title}" ایجاد شد`, "success");
}
ModalManager.close("rbBoardFormModal");
requestAnimationFrame(() => {
this.render(true);
});
},
deleteBoard(boardId) {
const board = projectData.appData.boards.find(
(b) => b.id === boardId,
);
if (!board) return;
if (!confirm(`حذف برد "${board.title}" و تمام محتویات آن؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.boards = projectData.appData.boards.filter(
(b) => b.id !== boardId,
);
if (this.activeBoardId === boardId)
this.activeBoardId = projectData.appData.boards[0]?.id || null;
showToast("برد حذف شد", "warning");
this.render(true);
},
openNewColumnModal() {
if (!this.getActiveBoard()) {
showToast("ابتدا یک برد ایجاد کنید", "warning");
return;
}
this.editing.columnId = null;
document.getElementById("rbColumnFormTitle").textContent =
"ایجاد ستون جدید";
document.getElementById("rbColumnTitle").value = "";
document.getElementById("rbColumnBgColor").value = "#edf2f7";
document.getElementById("rbColumnTextColor").value = "#1a202c";
ModalManager.open("rbColumnFormModal");
},
openEditColumnModal(colId) {
const board = this.getActiveBoard();
const col = board.columns.find((c) => c.id === colId);
if (!col) return;
this.editing.columnId = colId;
document.getElementById("rbColumnFormTitle").textContent =
"ویرایش ستون";
document.getElementById("rbColumnTitle").value = col.title;
document.getElementById("rbColumnBgColor").value =
col.backgroundColor || "#edf2f7";
document.getElementById("rbColumnTextColor").value =
col.textColor || "#1a202c";
ModalManager.open("rbColumnFormModal");
},
saveColumn() {
const title = document.getElementById("rbColumnTitle").value.trim();
if (!title) {
showToast("عنوان الزامی است", "error");
return;
}
const board = this.getActiveBoard();
if (!board) return;
const bgColor = document.getElementById("rbColumnBgColor").value;
const textColor = document.getElementById("rbColumnTextColor").value;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (this.editing.columnId) {
const col = board.columns.find(
(c) => c.id === this.editing.columnId,
);
if (col) {
col.title = title;
col.backgroundColor = bgColor;
col.textColor = textColor;
showToast("ستون بروزرسانی شد", "success");
}
} else {
board.columns.push({
id: `col-${Date.now()}`,
title,
backgroundColor: bgColor,
textColor: textColor,
cards: [],
});
showToast("ستون ایجاد شد", "success");
}
ModalManager.close("rbColumnFormModal");
requestAnimationFrame(() => {
this.render(true);
});
},
deleteColumn(colId) {
if (!confirm("حذف این ستون و تمام کارت‌های آن؟")) return;
const board = this.getActiveBoard();
if (!board) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
board.columns = board.columns.filter((c) => c.id !== colId);
showToast("ستون حذف شد", "warning");
this.render(true);
},
openNewCardModal(columnId) {
this.editing.cardId = null;
this.editing.columnForCard = columnId;
document.getElementById("rbCardFormTitle").textContent =
"ایجاد کارت جدید";
document.getElementById("rbCardTitle").value = "";
document.getElementById("rbCardDesc").value = "";
document.getElementById("rbCardDate").value = "";
document.getElementById("rbCardBgColor").value = "#ffffff";
document.getElementById("rbDeleteCardBtn").style.display = "none";
document.getElementById("rbCardHistoryGroup").style.display = "none";
this.tempData.cardTags = [];
this.tempData.checklist = [];
this.tempData.assignees = [];
this.tempData.dependencies = [];
this.renderCardTagsEditor();
this.renderChecklistEditor();
this.renderAssigneesEditor();
this.renderDependenciesEditor();
this.populateDependencySelect();
ModalManager.open("rbCardFormModal");
},
openEditCardModal(cardId) {
const board = this.getActiveBoard();
let card = null,
column = null;
for (const col of board.columns) {
const c = col.cards.find((x) => x.id === cardId);
if (c) {
card = c;
column = col;
break;
}
}
if (!card) return;
this.editing.cardId = cardId;
this.editing.columnForCard = column.id;
document.getElementById("rbCardFormTitle").textContent =
"ویرایش کارت";
document.getElementById("rbCardTitle").value = card.title;
document.getElementById("rbCardDesc").value = card.description || "";
if (card.estimatedDate) {
const d = new Date(card.estimatedDate);
const pad = (n) => n.toString().padStart(2, "0");
document.getElementById("rbCardDate").value =
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} else {
document.getElementById("rbCardDate").value = "";
}
document.getElementById("rbCardBgColor").value =
card.backgroundColor || "#ffffff";
document.getElementById("rbDeleteCardBtn").style.display =
"inline-flex";
document.getElementById("rbCardHistoryGroup").style.display = "block";
this.tempData.cardTags = [...(card.tags || [])];
this.tempData.checklist = (card.checklist || []).map((i) => ({
...i,
}));
this.tempData.assignees = [...(card.assignees || [])];
this.tempData.dependencies = [...(card.dependencies || [])];
this.renderCardTagsEditor();
this.renderChecklistEditor();
this.renderAssigneesEditor();
this.renderDependenciesEditor();
this.renderCardHistory();
this.populateDependencySelect();
ModalManager.open("rbCardFormModal");
},
renderCardTagsEditor() {
const container = document.getElementById("rbCardTagsContainer");
const tags = projectData.appData.boardTags || [];
if (tags.length === 0) {
container.innerHTML =
'<div style="font-size:12px;color:var(--text-muted);padding:10px;">تگی وجود ندارد</div>';
return;
}
container.innerHTML = tags
.map((tag) => {
const selected = this.tempData.cardTags.includes(tag.id);
return `<div class="tag-chip ${selected ? "selected" : ""}" style="background:${tag.color};cursor:pointer;" onclick="ReleaseBoardsManager.toggleCardTag('${tag.id}')">${this.escapeHtml(tag.name)}</div>`;
})
.join("");
},
toggleCardTag(tagId) {
const idx = this.tempData.cardTags.indexOf(tagId);
if (idx >= 0) this.tempData.cardTags.splice(idx, 1);
else this.tempData.cardTags.push(tagId);
this.renderCardTagsEditor();
},
renderChecklistEditor() {
const container = document.getElementById("rbChecklistContainer");
const list = this.tempData.checklist;
if (list.length === 0) {
container.innerHTML =
'<div style="font-size:12px;color:var(--text-muted);padding:10px;">آیتمی وجود ندارد</div>';
return;
}
container.innerHTML = list
.map(
(item, idx) =>
`<div style="display:flex;align-items:center;gap:8px;padding:6px;background:var(--card-bg);border-radius:4px;margin-bottom:4px;"><input type="checkbox" ${item.done ? "checked" : ""} onchange="ReleaseBoardsManager.toggleChecklist(${idx})"><span style="flex:1;font-size:13px;${item.done ? "text-decoration:line-through;opacity:0.6;" : ""}">${this.escapeHtml(item.title)}</span><button class="btn btn-xs btn-danger" onclick="ReleaseBoardsManager.removeChecklist(${idx})">✕</button></div>`,
)
.join("");
},
addChecklistItem() {
const input = document.getElementById("rbNewChecklistItem");
const title = input.value.trim();
if (!title) return;
this.tempData.checklist.push({
id: `cl-${Date.now()}`,
title,
done: false,
});
input.value = "";
this.renderChecklistEditor();
},
toggleChecklist(idx) {
this.tempData.checklist[idx].done =
!this.tempData.checklist[idx].done;
this.renderChecklistEditor();
},
removeChecklist(idx) {
this.tempData.checklist.splice(idx, 1);
this.renderChecklistEditor();
},
saveCard() {
const title = document.getElementById("rbCardTitle").value.trim();
const desc = document.getElementById("rbCardDesc").value.trim();
if (!title) {
showToast("عنوان الزامی است", "error");
return;
}
if (!desc) {
showToast("توضیحات الزامی است", "error");
return;
}
const board = this.getActiveBoard();
if (!board) return;
const dateVal = document.getElementById("rbCardDate").value;
const bgColor = document.getElementById("rbCardBgColor").value;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (this.editing.cardId) {
for (const col of board.columns) {
const card = col.cards.find((c) => c.id === this.editing.cardId);
if (card) {
// ✅ ثبت تاریخچه ویرایش
const oldTitle = card.title;
card.title = title;
card.description = desc;
card.estimatedDate = dateVal
? new Date(dateVal).toISOString()
: null;
card.backgroundColor = bgColor;
card.tags = [...this.tempData.cardTags];
card.checklist = this.tempData.checklist.map((i) => ({ ...i }));
card.assignees = [...this.tempData.assignees];
card.dependencies = [...this.tempData.dependencies];
card.updatedAt = new Date().toISOString();
if (!card.history) card.history = [];
card.history.push({
type: "edit",
title: `ویرایش کارت`,
from:
oldTitle !== title ? `"${oldTitle}" ← "${title}"` : null,
date: new Date().toISOString(),
});
break;
}
}
showToast("کارت بروزرسانی شد", "success");
} else {
const column = board.columns.find(
(c) => c.id === this.editing.columnForCard,
);
if (column) {
column.cards.push({
id: `card-${Date.now()}`,
title,
description: desc,
estimatedDate: dateVal ? new Date(dateVal).toISOString() : null,
backgroundColor: bgColor,
tags: [...this.tempData.cardTags],
checklist: this.tempData.checklist.map((i) => ({ ...i })),
assignees: [...this.tempData.assignees],
dependencies: [...this.tempData.dependencies],
history: [
{
type: "create",
title: "ایجاد کارت",
from: "—",
to: column.title,
date: new Date().toISOString(),
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
showToast("کارت ایجاد شد", "success");
}
}
ModalManager.close("rbCardFormModal");
requestAnimationFrame(() => {
this.render(true);
});
},
deleteCard() {
if (!confirm("حذف این کارت؟")) return;
const board = this.getActiveBoard();
if (!board) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
for (const col of board.columns) {
const idx = col.cards.findIndex(
(c) => c.id === this.editing.cardId,
);
if (idx >= 0) {
col.cards.splice(idx, 1);
break;
}
}
ModalManager.close("rbCardFormModal");
showToast("کارت حذف شد", "warning");
this.render(true);
},
bindFormEvents() {
if (this._eventsBound) return;
this._eventsBound = true;
document.getElementById("rbSaveBoardBtn").onclick = () =>
this.saveBoard();
document.getElementById("rbSaveColumnBtn").onclick = () =>
this.saveColumn();
document.getElementById("rbSaveCardBtn").onclick = () =>
this.saveCard();
document.getElementById("rbDeleteCardBtn").onclick = () =>
this.deleteCard();
document.getElementById("rbNewAssigneeName").onkeydown = (e) => {
if (e.key === "Enter") this.addAssignee();
};
document.getElementById("rbNewChecklistItem").onkeydown = (e) => {
if (e.key === "Enter") this.addChecklistItem();
};
},
bindToolbarEvents() {
document.getElementById("rbEditBoard").onclick = () =>
this.openEditBoardModal();
document.getElementById("rbManageTags").onclick = () =>
this.openTagManager();
document.getElementById("rbUndoBtn").onclick = () =>
HistoryManager.undo();
document.getElementById("rbRedoBtn").onclick = () =>
HistoryManager.redo();
},
updateUndoRedoButtons() {
const undoLen = HistoryManager.undoStack.length;
const redoLen = HistoryManager.redoStack.length;
if (
this._lastUndoRedoState.undo === undoLen &&
this._lastUndoRedoState.redo === redoLen
)
return;
this._lastUndoRedoState = { undo: undoLen, redo: redoLen };
const undoBtn = document.getElementById("rbUndoBtn");
const redoBtn = document.getElementById("rbRedoBtn");
if (undoBtn) undoBtn.disabled = undoLen === 0;
if (redoBtn) redoBtn.disabled = redoLen === 0;
},
updateFooterStats() {
const boards = projectData.appData.boards || [];
const totalCards = boards.reduce(
(sum, b) => sum + b.columns.reduce((s, c) => s + c.cards.length, 0),
0,
);
document.getElementById("rbFooterStats").innerHTML =
`<span>📋 بردها: <span class="stat-highlight">${boards.length}</span></span><span>📑 ستون‌ها: <span class="stat-highlight">${boards.reduce((s, b) => s + b.columns.length, 0)}</span></span><span>📝 کارت‌ها: <span class="stat-highlight">${totalCards}</span></span>`;
},
openTagManager() {
ModalManager.open("rbTagManagerModal");
this.renderTagList();
this.bindTagEvents();
},
renderTagList() {
const container = document.getElementById("rbTagList");
const countEl = document.getElementById("rbTagCount");
if (!container) return;
const tags = projectData.appData.boardTags || [];
if (countEl)
countEl.textContent = tags.length.toLocaleString("fa-IR");
if (tags.length === 0) {
container.innerHTML = `<div class="rb-tag-empty"><div class="rb-tag-empty-icon">🏷️</div><div>هنوز تگی ایجاد نشده است</div><div style="font-size:11px;margin-top:4px;">از فرم بالا اولین تگ را بسازید</div></div>`;
return;
}
container.innerHTML = tags
.map((tag) => {
const usageCount = this._countTagUsage(tag.id);
return `<div class="rb-tag-item" data-tag-id="${tag.id}"><div class="rb-tag-color-dot" style="background:${tag.color};"></div><span class="rb-tag-name">${this.escapeHtml(tag.name)}</span><span class="rb-tag-usage">${usageCount.toLocaleString("fa-IR")} کارت</span><div class="rb-tag-actions"><button class="rb-tag-action-btn" onclick="ReleaseBoardsManager.openEditTagModal('${tag.id}')" title="ویرایش">✏️</button><button class="rb-tag-action-btn danger" onclick="ReleaseBoardsManager.deleteTag('${tag.id}')" title="حذف">🗑️</button></div></div>`;
})
.join("");
},
addTag() {
const nameInput = document.getElementById("rbNewTagName");
const colorInput = document.getElementById("rbNewTagColor");
const name = nameInput.value.trim();
const color = colorInput.value;
if (!name) {
showToast("نام تگ الزامی است", "error");
nameInput.focus();
return;
}
const tags = projectData.appData.boardTags || [];
if (tags.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
showToast("تگی با این نام وجود دارد", "warning");
return;
}
HistoryManager.pushState();
if (!projectData.appData.boardTags)
projectData.appData.boardTags = [];
projectData.appData.boardTags.push({
id: `tag-${Date.now()}`,
name,
color,
});
AutosaveManager.scheduleSave();
nameInput.value = "";
colorInput.value = "#4299e1";
this.renderTagList();
this.renderFilterBar();
showToast(`تگ "${name}" ایجاد شد`, "success");
},
openEditTagModal(tagId) {
const tags = projectData.appData.boardTags || [];
const tag = tags.find((t) => t.id === tagId);
if (!tag) return;
this.editing.tagId = tagId;
document.getElementById("rbEditTagName").value = tag.name;
document.getElementById("rbEditTagColor").value = tag.color;
const preview = document.getElementById("rbEditTagPreview");
preview.textContent = tag.name;
preview.style.background = tag.color;
const nameInput = document.getElementById("rbEditTagName");
const colorInput = document.getElementById("rbEditTagColor");
nameInput.oninput = () => {
preview.textContent = nameInput.value || "نمونه";
};
colorInput.oninput = () => {
preview.style.background = colorInput.value;
};
ModalManager.open("rbEditTagModal");
},
saveTag() {
const name = document.getElementById("rbEditTagName").value.trim();
const color = document.getElementById("rbEditTagColor").value;
if (!name) {
showToast("نام تگ الزامی است", "error");
return;
}
const tags = projectData.appData.boardTags || [];
if (
tags.some(
(t) =>
t.name.toLowerCase() === name.toLowerCase() &&
t.id !== this.editing.tagId,
)
) {
showToast("تگی با این نام وجود دارد", "warning");
return;
}
const tag = tags.find((t) => t.id === this.editing.tagId);
if (!tag) return;
HistoryManager.pushState();
tag.name = name;
tag.color = color;
AutosaveManager.scheduleSave();
ModalManager.close("rbEditTagModal");
this.renderTagList();
this.renderFilterBar();
this.render(true);
showToast(`تگ "${name}" بروزرسانی شد`, "success");
},
deleteTag(tagId) {
const tags = projectData.appData.boardTags || [];
const tag = tags.find((t) => t.id === tagId);
if (!tag) return;
const usageCount = this._countTagUsage(tagId);
const confirmMsg =
usageCount > 0
? `حذف تگ "${tag.name}"؟\nاین تگ در ${usageCount} کارت استفاده شده و از همه آن‌ها حذف خواهد شد.`
: `حذف تگ "${tag.name}"؟`;
if (!confirm(confirmMsg)) return;
HistoryManager.pushState();
projectData.appData.boardTags = tags.filter((t) => t.id !== tagId);
const boards = projectData.appData.boards || [];
for (const board of boards) {
for (const col of board.columns) {
for (const card of col.cards) {
if (card.tags)
card.tags = card.tags.filter((tid) => tid !== tagId);
}
}
}
/* ✅ حذف تگ از فیلترهای فعال */
this.filters.tags = this.filters.tags.filter((tid) => tid !== tagId);
AutosaveManager.scheduleSave();
this.renderTagList();
this.renderFilterBar();
this.render(true);
showToast(`تگ "${tag.name}" حذف شد`, "warning");
},
bindTagEvents() {
document.getElementById("rbAddTagBtn").onclick = () => this.addTag();
document.getElementById("rbSaveTagBtn").onclick = () =>
this.saveTag();
document.getElementById("rbNewTagName").onkeydown = (e) => {
if (e.key === "Enter") this.addTag();
};
},
renderAssigneesEditor() {
const container = document.getElementById("rbAssigneesContainer");
if (!container) return;
const assignees = this.tempData.assignees || [];
if (assignees.length === 0) {
container.innerHTML =
'<span style="font-size:11px;color:var(--text-muted);padding:4px;">فردی مسئول نیست</span>';
return;
}
container.innerHTML = assignees
.map(
(a, idx) =>
`<span class="rb-item-chip assignee-chip">
👤 ${this.escapeHtml(a.name)}
<span class="chip-remove" onclick="event.stopPropagation();ReleaseBoardsManager.removeAssignee(${idx})">✕</span>
</span>`,
)
.join("");
},
addAssignee() {
const input = document.getElementById("rbNewAssigneeName");
const name = input.value.trim();
if (!name) return;
if (this.tempData.assignees.some((a) => a.name === name)) {
showToast("این فرد قبلاً اضافه شده است", "warning");
return;
}
this.tempData.assignees.push({
id: `assignee-${Date.now()}`,
name,
});
input.value = "";
this.renderAssigneesEditor();
},
removeAssignee(idx) {
this.tempData.assignees.splice(idx, 1);
this.renderAssigneesEditor();
},
populateDependencySelect() {
const select = document.getElementById("rbDependencySelect");
if (!select) return;
const board = this.getActiveBoard();
if (!board) return;
const currentCardId = this.editing.cardId;
let options = '<option value="">انتخاب کارت برای وابستگی...</option>';
board.columns.forEach((col) => {
col.cards.forEach((card) => {
if (card.id !== currentCardId) {
options += `<option value="${card.id}">${this.escapeHtml(card.title)} (${this.escapeHtml(col.title)})</option>`;
}
});
});
select.innerHTML = options;
},
renderDependenciesEditor() {
const container = document.getElementById("rbDependenciesContainer");
if (!container) return;
const deps = this.tempData.dependencies || [];
if (deps.length === 0) {
container.innerHTML =
'<span style="font-size:11px;color:var(--text-muted);padding:4px;">وابستگی‌ای وجود ندارد</span>';
return;
}
container.innerHTML = deps
.map((depId) => {
const depCard = this.findCardById(depId);
if (!depCard) return "";
return `<span class="rb-item-chip dependency-chip">
🔗 ${this.escapeHtml(depCard.title)}
<span class="chip-remove" onclick="event.stopPropagation();ReleaseBoardsManager.removeDependency('${depId}')">✕</span>
</span>`;
})
.join("");
},
addDependency() {
const select = document.getElementById("rbDependencySelect");
const depId = select.value;
if (!depId) {
showToast("کارتی انتخاب نشده است", "warning");
return;
}
if (this.tempData.dependencies.includes(depId)) {
showToast("این وابستگی قبلاً اضافه شده است", "warning");
return;
}
this.tempData.dependencies.push(depId);
select.value = "";
this.renderDependenciesEditor();
this.populateDependencySelect();
},
removeDependency(depId) {
this.tempData.dependencies = this.tempData.dependencies.filter(
(d) => d !== depId,
);
this.renderDependenciesEditor();
this.populateDependencySelect();
},
findCardById(cardId) {
const board = this.getActiveBoard();
if (!board) return null;
for (const col of board.columns) {
const card = col.cards.find((c) => c.id === cardId);
if (card) return card;
}
return null;
},
renderCardHistory() {
const container = document.getElementById("rbCardHistoryContainer");
if (!container) return;
const card = this.findCardById(this.editing.cardId);
if (!card || !card.history || card.history.length === 0) {
container.innerHTML =
'<span style="font-size:11px;color:var(--text-muted);padding:4px;">تاریخچه‌ای وجود ندارد</span>';
return;
}
const icons = {
create: "🆕",
edit: "✏️",
move: "📋",
};
container.innerHTML = card.history
.map((h) => {
const icon = icons[h.type] || "📝";
const date = new Date(h.date);
const dateStr =
date.toLocaleDateString("fa-IR") +
" " +
date.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
});
let content = h.title;
if (h.from && h.from !== "—") {
content += ` — از "${h.from}"`;
if (h.to) content += ` به "${h.to}"`;
}
return `<div class="rb-history-item">
<span class="rb-history-icon">${icon}</span>
<span class="rb-history-content">${this.escapeHtml(content)}</span>
<span class="rb-history-date">${dateStr}</span>
</div>`;
})
.join("");
},
escapeHtml(str) {
if (!str) return "";
return String(str).replace(
/[&<>"']/g,
(c) =>
({
"&": "&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);
setTimeout(() => {
document
.querySelectorAll(".dashboard-chart-body")
.forEach((body) => {
const svg = body.querySelector("svg");
if (svg) {
ChartZoomManager.enable(body, svg);
}
});
}, 200);
document.getElementById("depAnalysisFooterStats").innerHTML =
`<span>🔗 کل: <span class="stat-highlight">${d.totalDeps}</span></span><span>⚠️ گلوگاه: <span class="stat-highlight">${d.bottlenecks.length}</span></span><span>🔴 شکسته: <span class="stat-highlight">${d.brokenDeps}</span></span><span>🔵 ایزوله: <span class="stat-highlight">${d.isolated.length}</span></span>`;
},
renderHubChart(cid, bottlenecks) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!bottlenecks || bottlenecks.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>گلوگاهی نیست</div></div>';
return;
}
const w = 320,
hgt = Math.max(120, bottlenecks.length * 40 + 40);
const m = { top: 10, right: 35, bottom: 10, left: 130 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleLinear()
.domain([0, d3.max(bottlenecks, (d) => d.count) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(bottlenecks.map((d) => (d.project ? d.project.title : "")))
.range([0, ih])
.padding(0.3);
g.selectAll("rect")
.data(bottlenecks)
.join("rect")
.attr("x", 0)
.attr("y", (d) => y(d.project ? d.project.title : ""))
.attr("width", (d) => x(d.count))
.attr("height", y.bandwidth())
.attr("fill", (d) => (d.count >= 3 ? "#e53e3e" : "#dd6b20"))
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(
e,
`${d.project ? d.project.title : ""}: ${d.count} وابستگی ورودی`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, d) {
d3.select(this).attr(
"fill",
d.count >= 3 ? "#e53e3e" : "#dd6b20",
);
hideD3Tooltip();
});
g.selectAll(".label")
.data(bottlenecks)
.join("text")
.attr("x", -8)
.attr(
"y",
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "600")
.text((d) => {
const t = d.project ? d.project.title : "";
return t.length > 18 ? t.substring(0, 18) + "..." : t;
});
g.selectAll(".value")
.data(bottlenecks)
.join("text")
.attr("x", (d) => x(d.count) + 5)
.attr(
"y",
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.count);
},
renderTypeDonut(cid, typeCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(typeCounts);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>🥧</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((d) => d[1])
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const total = entries.reduce((s, d) => s + d[1], 0);
const colors = {
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
};
g.selectAll("path")
.data(pie(entries))
.join("path")
.attr("d", arc)
.attr("fill", (d) => colors[d.data[0]] || "#718096")
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).transition().duration(150).attr("d", arcH(d));
const ti = VALID_PROJECT_TYPES.find((t) => t.value === d.data[0]);
showD3Tooltip(
e,
`${ti ? ti.label : d.data[0]}: ${d.data[1]} (${Math.round((d.data[1] / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr("fill", "var(--text)")
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(total);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("وابستگی");
},
renderMatrix(cid, allProjects, matrix) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!allProjects || allProjects.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>🟥</span><div>داده‌ای نیست</div></div>';
return;
}
const cs = 28,
lw = 140;
const w = lw + allProjects.length * cs + 20;
const hgt = 40 + allProjects.length * cs + 20;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg.append("g");
allProjects.forEach((pr, i) => {
g.append("text")
.attr("x", lw - 8)
.attr("y", 40 + i * cs + cs / 2)
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-family", "Vazirmatn")
.text(
pr.title.length > 20
? pr.title.substring(0, 20) + "..."
: pr.title,
);
});
allProjects.forEach((pr, i) => {
g.append("text")
.attr("x", lw + i * cs + cs / 2)
.attr("y", 30)
.attr("text-anchor", "middle")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", `rotate(-45, ${lw + i * cs + cs / 2}, 30)`)
.text(
pr.title.length > 12
? pr.title.substring(0, 12) + "..."
: pr.title,
);
});
allProjects.forEach((row, i) => {
allProjects.forEach((col, j) => {
const hasDep = matrix[row.id] && matrix[row.id][col.id];
g.append("rect")
.attr("x", lw + j * cs)
.attr("y", 40 + i * cs)
.attr("width", cs - 2)
.attr("height", cs - 2)
.attr("rx", 3)
.attr("fill", hasDep ? "#e53e3e" : "var(--border-light)")
.attr("opacity", hasDep ? 0.9 : 0.5)
.style("cursor", hasDep ? "pointer" : "default")
.on(
"mouseover",
hasDep
? (e) => showD3Tooltip(e, `${row.title} → ${col.title}`)
: null,
)
.on("mousemove", hasDep ? (e) => showD3Tooltip(e, "") : null)
.on("mouseout", hasDep ? () => hideD3Tooltip() : null);
});
});
},
renderRiskList(cid, d) {
const c = document.getElementById(cid);
if (!c) return;
let h = "";
if (d.brokenDeps > 0)
h += `<div style="padding:10px 12px;background:rgba(229,62,62,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #e53e3e"><div style="font-weight:700;font-size:12px">🔴 وابستگی‌های شکسته</div><div style="font-size:11px;color:var(--text-muted)">${d.brokenDeps} وابستگی به پروژه‌های ناموجود</div></div>`;
d.bottlenecks.forEach((b) => {
h += `<div style="padding:10px 12px;background:rgba(221,107,32,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #dd6b20"><div style="font-weight:700;font-size:12px">⚠️ گلوگاه: ${b.project ? b.project.title : ""}</div><div style="font-size:11px;color:var(--text-muted)">${b.count} پروژه به این پروژه وابسته‌اند - ریسک بالا در صورت تغییر</div></div>`;
});
if (d.isolated.length > 0)
h += `<div style="padding:10px 12px;background:rgba(49,130,206,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #3182ce"><div style="font-weight:700;font-size:12px">🔵 پروژه‌های ایزوله</div><div style="font-size:11px;color:var(--text-muted)">${d.isolated.map((p) => p.title).join("، ")}</div></div>`;
if (!h)
h =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
c.innerHTML = h;
},
};
const DeploymentAnalysisManager = {
open() {
ModalManager.open("deploymentAnalysisModal");
setTimeout(() => this.render(), 150);
},
getData() {
let total = 0,
pwd = 0,
pwod = 0,
withNotes = 0,
withoutNotes = 0;
const edc = {},
ppc = [],
riskList = [];
const now = new Date();
const thirtyDaysAgo = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
);
projectData.appData.products.forEach((prod) => {
let prodDeployCount = 0;
prod.projects.forEach((pr) => {
const active = ProductManager.getActiveDeployments(pr);
if (active.length > 0) pwd++;
else {
pwod++;
riskList.push({
type: "no-deploy",
severity: "high",
title: "بدون استقرار",
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
});
}
active.forEach((d) => {
total++;
prodDeployCount++;
if (d.releaseNotes) withNotes++;
else {
withoutNotes++;
riskList.push({
type: "no-notes",
severity: "medium",
title: "بدون ریلیز نوت",
desc: `${pr.title} v${d.version}`,
rec: "ریلیز نوت را تکمیل کنید",
});
}
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
edc[envName] = (edc[envName] || 0) + 1;
if (
d.deploymentDate &&
new Date(d.deploymentDate) < thirtyDaysAgo
) {
const daysOld = Math.floor(
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
);
riskList.push({
type: "stale",
severity: daysOld > 90 ? "high" : "medium",
title: "استقرار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "بررسی و به‌روزرسانی نسخه",
});
}
});
});
ppc.push({ title: prod.title, count: prodDeployCount });
});
const coverage =
pwd + pwod > 0 ? Math.round((pwd / (pwd + pwod)) * 100) : 0;
return {
total,
pwd,
pwod,
withNotes,
withoutNotes,
edc,
ppc,
riskList,
coverage,
};
},
render() {
const c = document.getElementById("deploymentAnalysisContainer");
if (!c) return;
const d = this.getData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل استقرارها</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">✅</div><div class="dashboard-kpi-value">${d.pwd}</div><div class="dashboard-kpi-label">با استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">❌</div><div class="dashboard-kpi-value">${d.pwod}</div><div class="dashboard-kpi-label">بدون استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value">${d.coverage}%</div><div class="dashboard-kpi-label">پوشش استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📝</div><div class="dashboard-kpi-value">${d.withNotes}</div><div class="dashboard-kpi-label">با ریلیز نوت</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.withoutNotes}</div><div class="dashboard-kpi-label">بدون ریلیز نوت</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="deployEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژه‌ها</div><div class="dashboard-chart-body" id="deployStatusChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 استقرارها به تفکیک محصول</div><div class="dashboard-chart-body" id="deployProductChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها</div><div class="dashboard-chart-body" id="deployRiskList" style="align-items:flex-start;overflow-y:auto;max-height:280px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderEnvBarChart("deployEnvChart", d.edc);
this.renderStatusDonut(
"deployStatusChart",
d.pwd,
d.pwod,
d.coverage,
);
this.renderProductBarChart("deployProductChart", d.ppc);
this.renderRiskList("deployRiskList", d.riskList);
setTimeout(() => {
document
.querySelectorAll(".dashboard-chart-body")
.forEach((body) => {
const svg = body.querySelector("svg");
if (svg) {
ChartZoomManager.enable(body, svg);
}
});
}, 200);
document.getElementById("deployAnalysisFooterStats").innerHTML =
`<span>🚀 کل: <span class="stat-highlight">${d.total}</span></span><span>📈 پوشش: <span class="stat-highlight">${d.coverage}%</span></span><span>⚠️ ریسک‌ها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
},
renderEnvBarChart(cid, envCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(envCounts).filter((d) => d[1] > 0);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 45, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const xScale = d3
.scaleBand()
.domain(entries.map((d) => d[0]))
.range([0, iw])
.padding(0.3);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(entries, (d) => d[1]) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(xScale).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-20)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(yScale).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
// ✅ اصلاح: استفاده از d به جای x
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", (d) => xScale(d[0]))
.attr("y", (d) => yScale(d[1]))
.attr("width", xScale.bandwidth())
.attr("height", (d) => ih - yScale(d[1]))
.attr("fill", "#38a169")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d[0]}: ${d[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#38a169");
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(entries)
.join("text")
.attr("x", (d) => xScale(d[0]) + xScale.bandwidth() / 2)
.attr("y", (d) => yScale(d[1]) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d[1]);
},
renderStatusDonut(cid, pwd, pwod, coverage) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const total = pwd + pwod;
if (total === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((d) => d.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const data = [
{ label: "با استقرار", value: pwd, color: "#38a169" },
{ label: "بدون استقرار", value: pwod, color: "#e53e3e" },
].filter((d) => d.value > 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (d) => d.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).transition().duration(150).attr("d", arcH(d));
showD3Tooltip(
e,
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr(
"fill",
coverage >= 70
? "#38a169"
: coverage >= 40
? "#dd6b20"
: "#e53e3e",
)
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(`${coverage}%`);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("پوشش استقرار");
},
renderProductBarChart(cid, ppc) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const data = ppc.map((p) => ({
label:
p.title.length > 15 ? p.title.substring(0, 15) + "..." : p.title,
value: p.count,
}));
if (data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const bh = 28,
gap = 6;
const w = 320,
hgt = data.length * (bh + gap) + 30;
const m = { top: 10, right: 35, bottom: 10, left: 130 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const xScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 1])
.range([0, iw]);
const yScale = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, ih])
.padding(0.2);
// ✅ این متد از قبل درست بود (از d استفاده می‌کرد)
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", 0)
.attr("y", (d) => yScale(d.label))
.attr("width", (d) => xScale(d.value))
.attr("height", yScale.bandwidth())
.attr("fill", "#3182ce")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d.label}: ${d.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#3182ce");
hideD3Tooltip();
});
g.selectAll(".label")
.data(data)
.join("text")
.attr("x", -8)
.attr("y", (d) => yScale(d.label) + yScale.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((d) => d.label);
g.selectAll(".value")
.data(data)
.join("text")
.attr("x", (d) => xScale(d.value) + 5)
.attr("y", (d) => yScale(d.label) + yScale.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.value);
},
renderRiskList(cid, riskList) {
const c = document.getElementById(cid);
if (!c) return;
if (riskList.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
return;
}
const colors = {
critical: "#e53e3e",
high: "#dd6b20",
medium: "#d69e2e",
};
let h = "";
riskList.forEach((r) => {
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
h += `<div style="font-weight:700;font-size:12px">${r.title}</div>`;
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
h += "</div>";
});
c.innerHTML = h;
},
};
const VersionAnalysisManager = {
open() {
ModalManager.open("versionAnalysisModal");
setTimeout(() => this.render(), 150);
},
getData() {
let total = 0;
const uniqueVersions = new Set();
const majorCounts = {},
envCounts = {},
envMatrix = {},
riskList = [];
const now = new Date();
const thirtyDaysAgo = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
);
const ninetyDaysAgo = new Date(
now.getTime() - 90 * 24 * 60 * 60 * 1000,
);
const prereleaseRegex = /-(rc|beta|dev|alpha)/i;
projectData.appData.environments.forEach((e) => {
envCounts[e.name] = 0;
});
projectData.appData.products.forEach((prod) => {
(prod.projects || []).forEach((pr) => {
const active = ProductManager.getActiveDeployments(pr);
envMatrix[pr.title] = {};
active.forEach((d) => {
total++;
uniqueVersions.add(d.version);
const major = parseInt(d.version.split(".")[0]) || 0;
const majorKey = `v${major}.x`;
majorCounts[majorKey] = (majorCounts[majorKey] || 0) + 1;
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
envCounts[envName] = (envCounts[envName] || 0) + 1;
envMatrix[pr.title][d.environmentId] = d.version;
if (
d.deploymentDate &&
new Date(d.deploymentDate) < ninetyDaysAgo
) {
const daysOld = Math.floor(
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
);
riskList.push({
type: "stale-critical",
severity: "high",
icon: "⏰",
title: "نسخه بسیار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "فوراً بررسی و به‌روزرسانی کنید",
});
} else if (
d.deploymentDate &&
new Date(d.deploymentDate) < thirtyDaysAgo
) {
const daysOld = Math.floor(
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
);
riskList.push({
type: "stale-medium",
severity: "medium",
icon: "⏰",
title: "نسخه قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "برنامه‌ریزی به‌روزرسانی",
});
}
if (!d.releaseNotes)
riskList.push({
type: "no-notes",
severity: "medium",
icon: "📝",
title: "بدون ریلیز نوت",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "ریلیز نوت را تکمیل کنید",
});
if (prereleaseRegex.test(d.version)) {
if (d.environmentId === "env-prod-001")
riskList.push({
type: "prerelease-prod",
severity: "high",
icon: "🧪",
title: "نسخه پیش‌نویس در Production",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "نسخه پایدار را در Production مستقر کنید",
});
else
riskList.push({
type: "prerelease-other",
severity: "low",
icon: "🧪",
title: "نسخه پیش‌نویس",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "پس از تست، نسخه پایدار را منتشر کنید",
});
}
});
});
});
let avgMajor = 0,
avgMinor = 0,
avgPatch = 0;
if (uniqueVersions.size > 0) {
let sumMajor = 0,
sumMinor = 0,
sumPatch = 0;
uniqueVersions.forEach((v) => {
const parts = v.split(".");
sumMajor += parseInt(parts[0]) || 0;
sumMinor += parseInt(parts[1]) || 0;
sumPatch += parseInt(parts[2]) || 0;
});
avgMajor = Math.round(sumMajor / uniqueVersions.size);
avgMinor = Math.round(sumMinor / uniqueVersions.size);
avgPatch = Math.round(sumPatch / uniqueVersions.size);
}
const prereleaseCount = [...uniqueVersions].filter((v) =>
prereleaseRegex.test(v),
).length;
return {
total,
uniqueVersions: uniqueVersions.size,
avgVersion: `${avgMajor}.${avgMinor}.${avgPatch}`,
prereleaseCount,
majorCounts,
envCounts,
envMatrix,
riskList,
};
},
render() {
const c = document.getElementById("versionAnalysisContainer");
if (!c) return;
const d = this.getData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔢</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل نسخه‌ها</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.uniqueVersions}</div><div class="dashboard-kpi-label">نسخه‌های یکتا</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value" style="font-size:20px">${d.avgVersion}</div><div class="dashboard-kpi-label">میانگین نسخه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🧪</div><div class="dashboard-kpi-value">${d.prereleaseCount}</div><div class="dashboard-kpi-label">پیش‌نویس</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.riskList.length}</div><div class="dashboard-kpi-label">ریسک‌ها</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نسخه‌ها بر اساس Major</div><div class="dashboard-chart-body" id="verMajorChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 نسخه‌ها به تفکیک محیط</div><div class="dashboard-chart-body" id="verEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">📋 مقایسه نسخه‌ها بین محیط‌ها</div><div class="dashboard-chart-body" id="verEnvMatrix" style="overflow:auto;max-height:300px;width:100%"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها</div><div class="dashboard-chart-body" id="verRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderMajorBarChart("verMajorChart", d.majorCounts);
this.renderEnvBarChart("verEnvChart", d.envCounts);
this.renderEnvMatrix("verEnvMatrix", d.envMatrix);
this.renderRiskList("verRiskList", d.riskList);
setTimeout(() => {
document
.querySelectorAll(".dashboard-chart-body")
.forEach((body) => {
const svg = body.querySelector("svg");
if (svg) {
ChartZoomManager.enable(body, svg);
}
});
}, 200);
document.getElementById("verAnalysisFooterStats").innerHTML =
`<span>🔢 کل: <span class="stat-highlight">${d.total}</span></span><span>📦 یکتا: <span class="stat-highlight">${d.uniqueVersions}</span></span><span>📈 میانگین: <span class="stat-highlight">${d.avgVersion}</span></span><span>⚠️ ریسک‌ها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
},
renderMajorBarChart(cid, majorCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(majorCounts).sort((a, b) =>
a[0].localeCompare(b[0]),
);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 40, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const xScale = d3
.scaleBand()
.domain(entries.map((d) => d[0]))
.range([0, iw])
.padding(0.3);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(entries, (d) => d[1]) || 1])
.nice()
.range([ih, 0]);
const colors = [
"#4299e1",
"#48bb78",
"#ecc94b",
"#ed64a6",
"#9f7aea",
"#ed8936",
];
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(xScale).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px")
.attr("font-family", "Vazirmatn");
g.append("g")
.call(d3.axisLeft(yScale).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
// ✅ اصلاح: استفاده از d به جای x
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", (d) => xScale(d[0]))
.attr("y", (d) => yScale(d[1]))
.attr("width", xScale.bandwidth())
.attr("height", (d) => ih - yScale(d[1]))
.attr("fill", (d, i) => colors[i % colors.length])
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d[0]}: ${d[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, d) {
d3.select(this).attr(
"fill",
colors[entries.indexOf(d) % colors.length],
);
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(entries)
.join("text")
.attr("x", (d) => xScale(d[0]) + xScale.bandwidth() / 2)
.attr("y", (d) => yScale(d[1]) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d[1]);
},
renderEnvBarChart(cid, envCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(envCounts).filter((x) => x[1] > 0);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 45, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(entries.map((x) => x[0]))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(entries, (x) => x[1]) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-20)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", (x) => x(x[0]))
.attr("y", (x) => y(x[1]))
.attr("width", x.bandwidth())
.attr("height", (x) => ih - y(x[1]))
.attr("fill", "#38a169")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${x[0]}: ${x[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#38a169");
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(entries)
.join("text")
.attr("x", (x) => x(x[0]) + x.bandwidth() / 2)
.attr("y", (x) => y(x[1]) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((x) => x[1]);
},
renderEnvMatrix(cid, envMatrix) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const projects = Object.keys(envMatrix);
if (projects.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📋</span><div>داده‌ای نیست</div></div>';
return;
}
const envs = projectData.appData.environments || [];
let html =
'<table style="width:100%;border-collapse:collapse;font-size:11px">';
html += '<thead><tr style="background:var(--border-light)">';
html +=
'<th style="padding:8px;text-align:right;border-bottom:2px solid var(--border);font-weight:700">پروژه</th>';
envs.forEach((env) => {
html += `<th style="padding:8px;text-align:center;border-bottom:2px solid var(--border);font-weight:700"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${env.backgroundColor};margin-left:4px"></span>${env.name}</th>`;
});
html += "</tr></thead><tbody>";
projects.forEach((prTitle) => {
html += `<tr style="border-bottom:1px solid var(--border-light)">`;
html += `<td style="padding:6px 8px;font-weight:600">${prTitle}</td>`;
envs.forEach((env) => {
const version = envMatrix[prTitle][env.id];
if (version)
html += `<td style="padding:6px 8px;text-align:center"><span style="font-family:monospace;font-weight:700;color:var(--primary)">v${version}</span></td>`;
else
html +=
'<td style="padding:6px 8px;text-align:center;color:var(--text-muted)">—</td>';
});
html += "</tr>";
});
html += "</tbody></table>";
c.innerHTML = html;
},
renderRiskList(cid, riskList) {
const c = document.getElementById(cid);
if (!c) return;
if (riskList.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
return;
}
const severityOrder = { high: 0, medium: 1, low: 2 };
const sorted = [...riskList].sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
);
const colors = { high: "#dd6b20", medium: "#d69e2e", low: "#3182ce" };
let h = "";
sorted.forEach((r) => {
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
h += "</div>";
});
c.innerHTML = h;
},
};
const RiskAnalysisManager = {
open() {
ModalManager.open("riskAnalysisModal");
setTimeout(() => this.render(), 150);
},
analyzeRisks() {
const risks = [];
const now = new Date();
const thirtyDaysAgo = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
);
const ninetyDaysAgo = new Date(
now.getTime() - 90 * 24 * 60 * 60 * 1000,
);
const envs = projectData.appData.environments || [];
const envIdsWithDeployment = new Set();
projectData.appData.products.forEach((prod) => {
if (!prod.metadata?.owner)
risks.push({
type: "no-owner",
severity: "medium",
icon: "👤",
title: "بدون مالک",
desc: `محصول "${prod.title}" مالک مشخصی ندارد`,
rec: "مالک محصول را مشخص کنید",
});
(prod.projects || []).forEach((pr) => {
(pr.dependencies || []).forEach((depId) => {
if (!ProductManager.findProjectById(depId))
risks.push({
type: "broken",
severity: "critical",
icon: "🔴",
title: "وابستگی شکسته",
desc: `${pr.title} به پروژه ناموجود (${depId}) وابسته است`,
rec: "وابستگی را حذف یا پروژه مقصد را ایجاد کنید",
});
});
const active = ProductManager.getActiveDeployments(pr);
if (active.length === 0)
risks.push({
type: "no-deploy",
severity: "high",
icon: "❌",
title: "بدون استقرار",
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
});
active.forEach((d) => {
envIdsWithDeployment.add(d.environmentId);
if (d.requiredDeployments && d.requiredDeployments.length > 0) {
d.requiredDeployments.forEach((rd) => {
if (!ProductManager.findProjectById(rd.projectId))
risks.push({
type: "broken-req",
severity: "critical",
icon: "🔴",
title: "نیازمندی شکسته",
desc: `${pr.title} به پروژه ناموجود در نیازمندی‌ها ارجاع دارد`,
rec: "نیازمندی را بررسی و اصلاح کنید",
});
});
}
if (!d.releaseNotes) {
const envName = ProductManager.getEnvironmentName(
d.environmentId,
);
risks.push({
type: "no-notes",
severity: "medium",
icon: "📝",
title: "بدون ریلیز نوت",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "ریلیز نوت را تکمیل کنید",
});
}
if (d.deploymentDate) {
const depDate = new Date(d.deploymentDate);
const daysOld = Math.floor(
(now - depDate) / (1000 * 60 * 60 * 24),
);
const envName = ProductManager.getEnvironmentName(
d.environmentId,
);
if (depDate < ninetyDaysAgo)
risks.push({
type: "stale-critical",
severity: "high",
icon: "⏰",
title: "استقرار بسیار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "فوراً بررسی و به‌روزرسانی کنید",
});
else if (depDate < thirtyDaysAgo)
risks.push({
type: "stale-medium",
severity: "medium",
icon: "⏰",
title: "استقرار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "برنامه‌ریزی به‌روزرسانی",
});
}
});
});
});
envs.forEach((env) => {
if (!envIdsWithDeployment.has(env.id))
risks.push({
type: "empty-environment",
severity: "low",
icon: "🌍",
title: "محیط خالی",
desc: `محیط "${env.name}" هیچ استقراری ندارد`,
rec: "در صورت عدم نیاز، محیط را حذف کنید",
});
});
const crit = risks.filter((r) => r.severity === "critical").length;
const high = risks.filter((r) => r.severity === "high").length;
const med = risks.filter((r) => r.severity === "medium").length;
const low = risks.filter((r) => r.severity === "low").length;
let healthScore = 100 - crit * 15 - high * 10 - med * 5 - low * 2;
healthScore = Math.max(0, healthScore);
return {
risks,
crit,
high,
med,
low,
healthScore,
total: risks.length,
};
},
render() {
const c = document.getElementById("riskAnalysisContainer");
if (!c) return;
const d = this.analyzeRisks();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🎯</div><div class="dashboard-kpi-value">${d.healthScore}%</div><div class="dashboard-kpi-label">امتیاز سلامت</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.crit}</div><div class="dashboard-kpi-label">بحرانی</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟠</div><div class="dashboard-kpi-value">${d.high}</div><div class="dashboard-kpi-label">بالا</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟡</div><div class="dashboard-kpi-value">${d.med}</div><div class="dashboard-kpi-label">متوسط</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.low}</div><div class="dashboard-kpi-label">کم</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🍩 توزیع شدت ریسک‌ها</div><div class="dashboard-chart-body" id="riskSeverityChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نوع ریسک</div><div class="dashboard-chart-body" id="riskTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها و پیشنهادات</div><div class="dashboard-chart-body" id="riskDetailList" style="align-items:flex-start;overflow-y:auto;max-height:350px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderSeverityDonut("riskSeverityChart", d);
this.renderTypeBarChart("riskTypeChart", d.risks);
this.renderRiskList("riskDetailList", d.risks);
setTimeout(() => {
document
.querySelectorAll(".dashboard-chart-body")
.forEach((body) => {
const svg = body.querySelector("svg");
if (svg) {
ChartZoomManager.enable(body, svg);
}
});
}, 200);
document.getElementById("riskAnalysisFooterStats").innerHTML =
`<span>🎯 سلامت: <span class="stat-highlight">${d.healthScore}%</span></span><span>⚠️ ریسک‌ها: <span class="stat-highlight">${d.total}</span></span><span>🔴 بحرانی: <span class="stat-highlight">${d.crit}</span></span><span>🟠 بالا: <span class="stat-highlight">${d.high}</span></span>`;
},
renderSeverityDonut(cid, d) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const data = [
{ label: "بحرانی", value: d.crit, color: "#e53e3e" },
{ label: "بالا", value: d.high, color: "#dd6b20" },
{ label: "متوسط", value: d.med, color: "#d69e2e" },
{ label: "کم", value: d.low, color: "#3182ce" },
].filter((x) => x.value > 0);
if (data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((x) => x.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const total = data.reduce((s, x) => s + x.value, 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (x) => x.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).transition().duration(150).attr("d", arcH(x));
showD3Tooltip(
e,
`${x.data.label}: ${x.data.value} (${Math.round((x.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr(
"fill",
d.healthScore >= 70
? "#38a169"
: d.healthScore >= 40
? "#dd6b20"
: "#e53e3e",
)
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(`${d.healthScore}%`);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("امتیاز سلامت");
},
renderTypeBarChart(cid, risks) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const typeLabels = {
broken: "وابستگی شکسته",
"broken-req": "نیازمندی شکسته",
"no-deploy": "بدون استقرار",
"stale-critical": "استقرار بسیار قدیمی",
"stale-medium": "استقرار قدیمی",
"no-notes": "بدون ریلیز نوت",
"no-owner": "بدون مالک",
"empty-environment": "محیط خالی",
};
const typeColors = {
broken: "#e53e3e",
"broken-req": "#e53e3e",
"no-deploy": "#dd6b20",
"stale-critical": "#dd6b20",
"stale-medium": "#d69e2e",
"no-notes": "#d69e2e",
"no-owner": "#d69e2e",
"empty-environment": "#3182ce",
};
const typeCounts = {};
risks.forEach((r) => {
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
});
const entries = Object.entries(typeCounts).sort(
(a, b) => b[1] - a[1],
);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>ریسکی نیست</div></div>';
return;
}
const bh = 28,
gap = 6;
const w = 320,
hgt = entries.length * (bh + gap) + 30;
const m = { top: 10, right: 35, bottom: 10, left: 130 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const xScale = d3
.scaleLinear()
.domain([0, d3.max(entries, (d) => d[1]) || 1])
.range([0, iw]);
const yScale = d3
.scaleBand()
.domain(entries.map((d) => d[0]))
.range([0, ih])
.padding(0.2);
// ✅ اصلاح: استفاده از d به جای x در arrow functions
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", 0)
.attr("y", (d) => yScale(d[0]))
.attr("width", (d) => xScale(d[1]))
.attr("height", yScale.bandwidth())
.attr("fill", (d) => typeColors[d[0]] || "#718096")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${typeLabels[d[0]] || d[0]}: ${d[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, d) {
d3.select(this).attr("fill", typeColors[d[0]] || "#718096");
hideD3Tooltip();
});
g.selectAll(".label")
.data(entries)
.join("text")
.attr("x", -8)
.attr("y", (d) => yScale(d[0]) + yScale.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((d) => typeLabels[d[0]] || d[0]);
g.selectAll(".value")
.data(entries)
.join("text")
.attr("x", (d) => xScale(d[1]) + 5)
.attr("y", (d) => yScale(d[0]) + yScale.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d[1]);
},
renderRiskList(cid, risks) {
const c = document.getElementById(cid);
if (!c) return;
if (risks.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
return;
}
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
const sorted = [...risks].sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
);
const colors = {
critical: "#e53e3e",
high: "#dd6b20",
medium: "#d69e2e",
low: "#3182ce",
};
let h = "";
sorted.forEach((r) => {
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
h += "</div>";
});
c.innerHTML = h;
},
};
/* ==================== EVENT BINDINGS ==================== */
document
.getElementById("projectDetailDeleteBtn")
.addEventListener("click", () => {
if (ProductManager.currentDetailProductId)
ProductManager.removeProduct(ProductManager.currentDetailProductId);
});
document
.getElementById("saveProductEditBtn")
.addEventListener("click", () => ProductManager.saveProductEdit());
document
.getElementById("saveNewProjectBtn")
.addEventListener("click", () => ProductManager.saveNewProject());
document
.getElementById("saveProjEditBtn")
.addEventListener("click", () => ProductManager.saveProjectEdit());
document
.getElementById("saveDeploymentBtn")
.addEventListener("click", () => ProductManager.saveDeployment());
document
.getElementById("saveEnvEditBtn")
.addEventListener("click", () => EnvironmentManager.saveEdit());
document
.getElementById("saveNewProductBtn")
.addEventListener("click", () => ProductManager.createNewProduct());
document
.getElementById("saveNewEnvBtn")
.addEventListener("click", () => EnvironmentManager.saveNewEnv());
document
.getElementById("newEnvBgColor")
.addEventListener("input", (e) => {
document.getElementById("newEnvBgColorText").textContent =
e.target.value;
});
document
.getElementById("newEnvTextColor")
.addEventListener("input", (e) => {
document.getElementById("newEnvTextColorText").textContent =
e.target.value;
});
document
.getElementById("editEnvBgColor")
.addEventListener("input", (e) => {
document.getElementById("editEnvBgColorText").textContent =
e.target.value;
EnvironmentManager.updateEditPreview();
});
document
.getElementById("editEnvTextColor")
.addEventListener("input", (e) => {
document.getElementById("editEnvTextColorText").textContent =
e.target.value;
EnvironmentManager.updateEditPreview();
});
document
.getElementById("galaxyResetView")
.addEventListener("click", () => {
if (GalaxyChartManager.svg && GalaxyChartManager.zoomBehavior)
GalaxyChartManager.svg
.transition()
.duration(500)
.call(GalaxyChartManager.zoomBehavior.transform, d3.zoomIdentity);
});
document
.getElementById("galaxyInfoClose")
.addEventListener("click", () => {
document
.getElementById("galaxyInfoPanel")
.classList.remove("visible");
});
document
.getElementById("galaxyCollapseAll")
.addEventListener("click", () => {
GalaxyChartManager.collapseAllProducts();
});
document
.getElementById("galaxyExpandAll")
.addEventListener("click", () => {
GalaxyChartManager.expandAllProducts();
});
document
.getElementById("galaxyExportPNG")
.addEventListener("click", () => {
GalaxyChartManager.exportPNG();
showToast("📷 خروجی ذخیره شد", "success");
});
function handleToolSelect(tool, group) {
const ah = ActionRegistry[tool.action] || ActionRegistry.defaultAction;
ah(tool, group);
}
const themeToggle = document.getElementById("themeToggle"),
themeIcon = document.getElementById("themeIcon"),
htmlEl = document.documentElement;
const savedTheme = localStorage.getItem("theme") || "light";
htmlEl.setAttribute("data-theme", savedTheme);
if (themeIcon)
themeIcon.textContent = savedTheme === "dark" ? "☀️" : "🌙";
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const c = htmlEl.getAttribute("data-theme");
const n = c === "dark" ? "light" : "dark";
htmlEl.setAttribute("data-theme", n);
localStorage.setItem("theme", n);
if (themeIcon) themeIcon.textContent = n === "dark" ? "☀️" : "🌙";
});
}
const sidebarToggle = document.getElementById("sidebarToggle"),
sidebar = document.getElementById("sidebar"),
sidebarIcon = document.getElementById("sidebarIcon");
function updateSidebarIcon() {
if (!sidebarIcon) return;
sidebarIcon.textContent = sidebar.classList.contains("collapsed")
? "☰"
: "✕";
}
if (sidebarToggle) {
sidebarToggle.addEventListener("click", () => {
sidebar.classList.toggle("collapsed");
updateSidebarIcon();
});
}
updateSidebarIcon();
const sidebarBody = document.getElementById("sidebarBody");
function renderToolGroups() {
sidebarBody.innerHTML = "";
projectData.toolGroups.forEach((group) => {
const ge = document.createElement("div");
ge.className = "sidebar-group";
const he = document.createElement("div");
he.className = "sidebar-group-header";
he.innerHTML = `<div class="group-icon"><span>${group.icon || ""}</span><span>${group.title}</span></div><span class="group-arrow">▼</span>`;
const be = document.createElement("div");
be.className = "sidebar-group-body";
group.tools.forEach((tool) => {
const btn = document.createElement("button");
btn.className = "sidebar-btn";
btn.title = tool.description;
btn.innerHTML = `<span style="width:20px;text-align:center">${tool.icon || "🔧"}</span><span style="flex:1">${tool.title}</span>`;
btn.addEventListener("click", () => handleToolSelect(tool, group));
be.appendChild(btn);
});
he.addEventListener("click", () => ge.classList.toggle("collapsed"));
ge.appendChild(he);
ge.appendChild(be);
sidebarBody.appendChild(ge);
});
}
function openSettingsModal() {
document.getElementById("settingProjectTitle").value =
projectData.projectTitle;
document.getElementById("settingVersion").value = projectData.version;
document.getElementById("settingDescription").value =
projectData.description;
document.getElementById("settingDeveloper").value =
projectData.developer;
document.getElementById("settingCompany").value = projectData.company;
document.getElementById("settingWebhookUrl").value =
projectData.webhook.url;
document.getElementById("settingWebhookClientId").value =
projectData.webhook.clientId;
document.getElementById("settingWebhookEnabled").checked =
projectData.webhook.enabled;
WebhookManager.updateStatusUI();
ModalManager.open("settingsModal");
}
[
"settingWebhookUrl",
"settingWebhookClientId",
"settingWebhookEnabled",
].forEach((id) => {
document.getElementById(id).addEventListener("input", () => {
WebhookManager.setConfig({
url: document.getElementById("settingWebhookUrl").value,
clientId: document.getElementById("settingWebhookClientId").value,
enabled: document.getElementById("settingWebhookEnabled").checked,
});
});
});
document
.getElementById("saveSettingsBtn")
.addEventListener("click", () => {
WebhookManager.setConfig({
url: document.getElementById("settingWebhookUrl").value.trim(),
clientId: document
.getElementById("settingWebhookClientId")
.value.trim(),
enabled: document.getElementById("settingWebhookEnabled").checked,
});
ModalManager.close("settingsModal");
showToast("تنظیمات ذخیره شد", "success");
});
function updateProjectUI() {
document.title = projectData.projectTitle;
document.getElementById("headerTitle").textContent =
projectData.projectTitle;
document.getElementById("headerSubtitle").textContent =
projectData.description;
document.getElementById("appFooter").innerHTML =
`<div><span class="footer-brand">طراح و توسعه‌دهنده:</span> ${projectData.developer} <span style="margin:0 10px">|</span><span class="footer-brand">شرکت:</span> ${projectData.company}</div><div><span class="footer-brand">نسخه:</span> ${projectData.version}</div>`;
}
/* ==================== INIT ==================== */
ModalManager.init();
renderToolGroups();
updateProjectUI();
WebhookManager.updateStatusUI();
ToolbarManager.init();
SectionCollapseManager.init();
BoardRenderer.render();
HistoryManager.init();
AutosaveManager.checkRecovery();
window.addEventListener("beforeunload", () => {
AutosaveManager.save();
});
setTimeout(
() =>
showToast(
`${projectData.projectTitle} (نسخه ${projectData.version}) آماده است`,
"success",
),
500,
);
</script>
</body>
</html>