Files
xReleaseAsistant/xReleaseAssistant_6.html
T
2026-09-18 17:21:33 +03:30

9713 lines
359 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>دستیار انتشار</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap");
:root {
--primary: #1a365d;
--primary-light: #2c5282;
--primary-dark: #0f2341;
--accent: #c9a961;
--accent-dark: #a8893f;
--accent-light: #e0c789;
--bg: #f5f7fa;
--board-bg: #e2e8f0;
--card-bg: #fff;
--panel-bg: #fff;
--text: #1a202c;
--text-muted: #4a5568;
--text-light: #718096;
--border: #cbd5e0;
--border-light: #e2e8f0;
--success: #38a169;
--danger: #e53e3e;
--warning: #dd6b20;
--info: #3182ce;
--column-bg: #edf2f7;
--shadow:
0 4px 6px rgba(0, 0, 0, 0.08), 0 10px 20px rgba(26, 54, 93, 0.1);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.15);
--radius: 10px;
--radius-sm: 6px;
}
[data-theme="dark"] {
--primary: #2c5282;
--primary-light: #3b6fb5;
--primary-dark: #1a365d;
--accent: #e0c789;
--accent-dark: #c9a961;
--accent-light: #f0d9a0;
--bg: #1a202c;
--board-bg: #2d3748;
--card-bg: #2d3748;
--panel-bg: #1f2937;
--text: #e2e8f0;
--text-muted: #cbd5e0;
--text-light: #a0aec0;
--column-bg: #374151;
--border: #4a5568;
--border-light: #374151;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3), 0 10px 20px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Vazirmatn", Tahoma, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
font-size: 14px;
display: flex;
flex-direction: column;
transition:
background 0.3s,
color 0.3s;
}
.app-header {
background: linear-gradient(
135deg,
var(--primary) 0%,
var(--primary-light) 100%
);
color: #fff;
padding: 0 20px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid var(--accent);
box-shadow: var(--shadow);
z-index: 100;
flex-shrink: 0;
}
.app-brand {
display: flex;
align-items: center;
gap: 14px;
}
.app-logo {
width: 44px;
height: 44px;
background: var(--accent);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 800;
color: var(--primary-dark);
}
.app-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.app-title {
font-size: 16px;
font-weight: 800;
color: #fff;
}
.app-subtitle {
font-size: 11px;
opacity: 0.85;
color: rgba(255, 255, 255, 0.9);
}
.btn {
background: linear-gradient(
135deg,
var(--primary-light),
var(--primary)
);
color: #fff;
border: none;
padding: 8px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
white-space: nowrap;
}
.btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.btn-accent {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: var(--primary-dark);
font-weight: 700;
}
.btn-ghost {
background: rgba(26, 54, 93, 0.08);
border: 1px solid var(--border);
color: var(--primary);
}
[data-theme="dark"] .btn-ghost {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
}
.btn-danger {
background: linear-gradient(135deg, var(--danger), #c53030);
color: #fff;
}
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
.btn-xs {
padding: 2px 6px;
font-size: 10px;
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar {
width: 280px;
background: var(--panel-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: width 0.3s;
overflow: hidden;
flex-shrink: 0;
z-index: 50;
}
.sidebar.collapsed {
width: 0;
}
.sidebar-header {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--accent);
min-height: 48px;
}
.sidebar-title {
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.sidebar-group {
margin-bottom: 6px;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--card-bg);
}
.sidebar-group-header {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 10px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 700;
user-select: none;
}
.sidebar-group-header .group-icon {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.sidebar-group-header .group-arrow {
transition: transform 0.3s;
font-size: 12px;
}
.sidebar-group.collapsed .group-arrow {
transform: rotate(90deg);
}
.sidebar-group.collapsed .sidebar-group-body {
display: none;
}
.sidebar-group-body {
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-btn {
background: var(--card-bg);
border: 1px solid var(--border-light);
color: var(--text);
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
text-align: right;
width: 100%;
}
.sidebar-btn:hover {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
}
.tabs-bar {
background: linear-gradient(135deg, #0f1724 0%, #1a2a44 100%);
border-bottom: 2px solid var(--accent);
display: flex;
align-items: center;
padding: 0 12px;
min-height: 48px;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-controls {
display: flex;
gap: 6px;
margin-left: 8px;
}
.toolbar-icon-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #fff;
width: 34px;
height: 34px;
border-radius: 6px;
cursor: pointer;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-icon-btn:hover:not(:disabled) {
background: var(--accent);
color: var(--primary-dark);
}
.toolbar-search {
flex: 1;
min-width: 180px;
max-width: 320px;
position: relative;
}
.search-input {
width: 100%;
padding: 7px 32px 7px 12px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-family: inherit;
font-size: 13px;
}
.search-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.toolbar-filters {
display: flex;
gap: 8px;
}
.filter-dropdown {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 0 10px;
}
.filter-select {
background: transparent;
border: none;
color: #fff;
font-family: inherit;
font-size: 12px;
font-weight: 600;
padding: 7px 4px;
cursor: pointer;
outline: none;
min-width: 110px;
}
.filter-select option {
background: var(--primary-dark);
color: #fff;
}
.toolbar-stats {
display: flex;
gap: 12px;
margin-right: auto;
padding-right: 8px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: rgba(255, 255, 255, 0.85);
}
.stat-value {
font-weight: 800;
color: var(--accent);
background: rgba(201, 169, 97, 0.15);
padding: 2px 7px;
border-radius: 10px;
font-size: 11px;
}
.board-area {
flex: 1;
overflow: auto;
padding: 24px;
background: var(--board-bg);
}
.board-section {
margin-bottom: 32px;
}
.board-section.collapsed .section-content {
display: none;
}
.board-section.collapsed .collapse-icon {
transform: rotate(90deg);
}
.collapse-icon {
transition: transform 0.3s;
font-size: 14px;
display: inline-block;
margin-left: 6px;
}
.board-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border);
}
.board-header-title {
font-size: 18px;
font-weight: 800;
color: var(--primary);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
[data-theme="dark"] .board-header-title {
color: var(--accent);
}
.board-header-actions {
display: flex;
gap: 8px;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.project-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: all 0.25s;
display: flex;
flex-direction: column;
}
.project-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--accent);
}
.project-card-header {
background: linear-gradient(
135deg,
var(--primary) 0%,
var(--primary-light) 100%
);
color: #fff;
padding: 16px;
}
.project-card-title-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.project-card-title {
font-size: 16px;
font-weight: 700;
flex: 1;
}
.project-card-desc {
font-size: 12px;
opacity: 0.9;
line-height: 1.5;
}
.project-card-body {
padding: 14px 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.project-stat-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
.project-stat-value {
font-weight: 700;
color: var(--primary);
}
[data-theme="dark"] .project-stat-value {
color: var(--accent);
}
.project-type-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.type-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
}
.type-backend {
background: #ebf4ff;
color: #2b6cb0;
}
.type-frontend {
background: #f0fff4;
color: #276749;
}
.type-service {
background: #fefcbf;
color: #975a16;
}
.type-database {
background: #fed7e2;
color: #97266d;
}
.type-mobile {
background: #e9d8fd;
color: #553c9a;
}
.type-infrastructure {
background: #feebc8;
color: #9c4221;
}
.project-card-footer {
padding: 12px 16px;
background: var(--border-light);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.card-actions {
display: flex;
gap: 6px;
}
.env-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.env-card-item {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
}
.env-card-preview {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.env-card-body {
padding: 14px 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.env-card-name {
font-size: 15px;
font-weight: 700;
}
.env-card-desc {
font-size: 12px;
color: var(--text-muted);
}
.env-card-footer {
padding: 10px 16px;
background: var(--border-light);
border-top: 1px solid var(--border);
display: flex;
gap: 6px;
}
.board-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.board-empty-icon {
font-size: 80px;
opacity: 0.3;
margin-bottom: 20px;
}
.board-empty h2 {
font-size: 20px;
color: var(--primary);
margin-bottom: 10px;
}
[data-theme="dark"] .board-empty h2 {
color: var(--accent);
}
.no-results {
text-align: center;
padding: 40px;
color: var(--text-muted);
background: var(--border-light);
border-radius: var(--radius);
margin: 20px 0;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(26, 54, 93, 0.6);
backdrop-filter: blur(3px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--panel-bg);
color: var(--text);
border-radius: 12px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
}
.modal-lg {
max-width: 800px;
}
.modal-xl {
max-width: 1000px;
}
.modal-galaxy {
max-width: 1200px;
width: 100%;
height: 85vh;
max-height: 85vh;
border-radius: 16px;
}
.galaxy-search {
position: relative;
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0 8px;
height: 32px;
min-width: 230px;
}
.galaxy-search-icon {
font-size: 12px;
opacity: 0.85;
}
.galaxy-search input {
flex: 1;
width: 140px;
background: transparent;
border: none;
color: #fff;
font-family: inherit;
font-size: 12px;
outline: none;
}
.galaxy-collapse-badge {
pointer-events: none;
}
.galaxy-search input::placeholder {
color: rgba(255, 255, 255, 0.55);
}
.galaxy-search-clear {
background: none;
border: none;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-size: 12px;
opacity: 0;
pointer-events: none;
}
.galaxy-search-clear.visible {
opacity: 1;
pointer-events: auto;
}
.galaxy-node.galaxy-dim {
opacity: 0.12;
}
.galaxy-node.galaxy-match > circle:nth-of-type(2) {
stroke: #ffd700;
stroke-width: 3;
}
.galaxy-link.galaxy-dim {
stroke-opacity: 0.04 !important;
}
.modal-header {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid var(--accent);
flex-wrap: wrap;
gap: 10px;
}
.modal-title {
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: rgba(255, 255, 255, 0.15);
border: none;
color: #fff;
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
}
.modal-close:hover {
background: var(--danger);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-start;
gap: 8px;
background: var(--border-light);
align-items: center;
flex-wrap: wrap;
}
.detail-section {
margin-bottom: 20px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.detail-section-header {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 12px 16px;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
}
.detail-section-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-section-body {
padding: 16px;
background: var(--card-bg);
}
.detail-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.detail-info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-info-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
}
.detail-info-value {
font-size: 13px;
font-weight: 600;
}
.subproject-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.subproject-card {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--border-light);
}
.subproject-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.subproject-title {
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.subproject-actions {
display: flex;
gap: 4px;
}
.subproject-meta {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
}
.deployment-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.deployment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.deployment-header-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.deployment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.deployment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--card-bg);
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 11px;
flex-wrap: wrap;
gap: 6px;
}
.deployment-item-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.deployment-env-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.deployment-version {
font-family: "Vazirmatn", monospace;
font-weight: 700;
color: var(--primary);
font-size: 11px;
}
[data-theme="dark"] .deployment-version {
color: var(--accent);
}
.deployment-date {
font-size: 10px;
color: var(--text-muted);
}
.deployment-actions {
display: flex;
gap: 4px;
}
.deployment-notes {
font-size: 10px;
color: var(--text-muted);
width: 100%;
margin-top: 4px;
padding-top: 4px;
border-top: 1px dashed var(--border-light);
line-height: 1.5;
}
.deployment-req {
font-size: 10px;
color: var(--info);
width: 100%;
margin-top: 2px;
}
.empty-deployments {
text-align: center;
padding: 12px;
color: var(--text-muted);
font-size: 11px;
background: var(--border-light);
border-radius: 6px;
}
.empty-subprojects {
text-align: center;
padding: 30px;
color: var(--text-muted);
background: var(--border-light);
border-radius: var(--radius-sm);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--primary);
margin-bottom: 6px;
}
[data-theme="dark"] .form-label {
color: var(--accent-light);
}
.form-label .required {
color: var(--danger);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
background: var(--card-bg);
color: var(--text);
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(201, 169, 97, 0.15);
}
.form-textarea {
resize: vertical;
min-height: 70px;
line-height: 1.6;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.form-error {
font-size: 11px;
color: var(--danger);
margin-top: 4px;
display: none;
}
.form-error.visible {
display: block;
}
.color-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.color-input-wrapper input[type="color"] {
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.color-preview-text {
font-size: 11px;
font-family: monospace;
color: var(--text-muted);
}
.webhook-section {
background: var(--border-light);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
margin-top: 12px;
}
.webhook-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.webhook-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary);
display: flex;
align-items: center;
gap: 6px;
}
.webhook-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
}
.switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--border);
transition: 0.3s;
border-radius: 20px;
}
.slider::before {
position: absolute;
content: "";
height: 14px;
width: 14px;
right: 3px;
bottom: 3px;
background: #fff;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background: var(--success);
}
input:checked + .slider::before {
transform: translateX(-16px);
}
.webhook-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
background: var(--card-bg);
border-radius: 4px;
font-size: 11px;
color: var(--text-muted);
}
.webhook-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.webhook-status-dot.success {
background: var(--success);
}
.webhook-status-dot.error {
background: var(--danger);
}
.toast-container {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: var(--primary);
color: #fff;
padding: 12px 18px;
border-radius: 8px;
box-shadow: var(--shadow-lg);
font-size: 13px;
display: flex;
align-items: center;
gap: 10px;
min-width: 250px;
}
.toast.success {
background: var(--success);
}
.toast.error {
background: var(--danger);
}
.toast.warning {
background: var(--warning);
}
.app-footer {
background: linear-gradient(
135deg,
var(--primary),
var(--primary-light)
);
color: #fff;
padding: 8px 20px;
font-size: 11px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 2px solid var(--accent);
flex-shrink: 0;
}
.footer-brand {
color: var(--accent);
font-weight: 700;
}
.form-input[readonly],
.form-textarea[readonly] {
background: var(--border-light);
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.85;
border-style: dashed;
}
.readonly-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background: var(--border-light);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 10px;
margin-right: 6px;
}
.readonly-badge::before {
content: "🔒";
font-size: 10px;
}
.readonly-info {
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
border: 1px solid #90cdf4;
border-right: 4px solid var(--info);
padding: 10px 14px;
border-radius: 6px;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.dependency-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
max-height: 200px;
overflow-y: auto;
}
.dependency-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.dependency-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.dependency-project-name {
font-weight: 600;
font-size: 13px;
}
.dependency-search-box {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.dependency-search-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
background: var(--card-bg);
color: var(--text);
margin-bottom: 12px;
}
.dependency-search-results {
max-height: 300px;
overflow-y: auto;
}
.dependency-project-result {
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.dependency-project-add-btn {
padding: 10px 12px;
background: var(--card-bg);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
width: 100%;
border: none;
font-family: inherit;
text-align: right;
}
.dependency-project-add-btn:hover {
background: var(--accent-light);
}
.empty-dependencies {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-size: 12px;
background: var(--border-light);
border-radius: var(--radius-sm);
}
.dependency-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
[data-theme="dark"] .dependency-section-title {
color: var(--accent);
}
.env-color-preview {
padding: 10px;
border-radius: 6px;
text-align: center;
font-weight: 700;
border: 1px solid var(--border);
}
.env-read-only-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
padding: 6px 10px;
border-radius: 6px;
}
.req-deployment-box {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px;
margin-bottom: 8px;
background: var(--border-light);
}
.req-deployment-title {
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.req-deployment-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 12px;
background: var(--card-bg);
color: var(--text);
}
.galaxy-toolbar {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.galaxy-toolbar .btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 11px;
padding: 5px 10px;
}
.galaxy-toolbar .btn:hover {
background: var(--accent);
color: var(--primary-dark);
}
.galaxy-modal-body {
padding: 0;
position: relative;
overflow: hidden;
background: #0a0e27;
}
.galaxy-chart-container {
width: 100%;
height: 100%;
min-height: 400px;
position: relative;
}
.galaxy-chart-container svg {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
.galaxy-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.5);
text-align: center;
font-size: 16px;
gap: 10px;
}
.galaxy-legend {
position: absolute;
bottom: 16px;
right: 16px;
background: rgba(10, 14, 39, 0.85);
border: 1px solid rgba(201, 169, 97, 0.3);
border-radius: 10px;
padding: 12px 16px;
color: #fff;
font-size: 11px;
z-index: 10;
max-width: 220px;
}
.galaxy-legend-title {
font-weight: 700;
font-size: 12px;
margin-bottom: 10px;
color: var(--accent-light);
}
.galaxy-legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.galaxy-legend-color {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.galaxy-info-panel {
position: absolute;
top: 16px;
left: 16px;
background: rgba(10, 14, 39, 0.9);
border: 1px solid rgba(201, 169, 97, 0.3);
border-radius: 10px;
padding: 14px 18px;
color: #fff;
font-size: 12px;
z-index: 10;
max-width: 340px;
max-height: 400px;
overflow-y: auto;
display: none;
}
.galaxy-info-panel.visible {
display: block;
}
.galaxy-info-panel h4 {
font-size: 14px;
font-weight: 700;
color: var(--accent-light);
margin-bottom: 10px;
}
.galaxy-info-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
gap: 12px;
}
.galaxy-info-label {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.galaxy-info-value {
font-weight: 600;
font-size: 11px;
text-align: left;
}
.galaxy-zoom-indicator {
position: absolute;
bottom: 16px;
left: 16px;
background: rgba(10, 14, 39, 0.85);
border: 1px solid rgba(201, 169, 97, 0.3);
border-radius: 8px;
padding: 6px 12px;
color: #fff;
font-size: 12px;
z-index: 10;
font-weight: 600;
}
.galaxy-stats {
font-size: 12px;
color: var(--text-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.galaxy-stat-value {
font-weight: 700;
color: var(--primary);
background: rgba(26, 54, 93, 0.1);
padding: 2px 8px;
border-radius: 8px;
}
[data-theme="dark"] .galaxy-stat-value {
color: var(--accent);
background: rgba(201, 169, 97, 0.15);
}
.galaxy-search-results {
position: absolute;
top: 60px;
right: 16px;
width: 320px;
max-height: 320px;
overflow-y: auto;
background: rgba(10, 14, 39, 0.95);
border: 1px solid rgba(201, 169, 97, 0.35);
border-radius: 10px;
color: #fff;
z-index: 20;
display: none;
padding: 6px;
backdrop-filter: blur(10px);
}
.galaxy-search-results.visible {
display: block;
}
.galaxy-search-result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.galaxy-search-result-item:hover {
background: rgba(201, 169, 97, 0.25);
}
.galaxy-search-result-icon {
font-size: 16px;
flex-shrink: 0;
}
.galaxy-search-result-text {
min-width: 0;
}
.galaxy-search-result-title {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.galaxy-search-result-sub {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.galaxy-search-empty {
padding: 14px;
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.galaxy-node.galaxy-focus > circle:nth-of-type(2) {
stroke: #ffd700;
stroke-width: 4;
animation: galaxyPulse 0.9s ease-in-out 2;
}
.galaxy-node circle {
transform-box: fill-box;
transform-origin: center;
}
@keyframes galaxyPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
.dashboard-modal-body {
padding: 20px;
background: var(--bg);
overflow-y: auto;
}
.dashboard-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.dashboard-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.dashboard-kpi-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
position: relative;
overflow: hidden;
}
.dashboard-kpi-card::before {
content: "";
position: absolute;
top: 0;
right: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary), var(--accent));
}
.dashboard-kpi-icon {
font-size: 28px;
margin-bottom: 8px;
}
.dashboard-kpi-value {
font-size: 28px;
font-weight: 800;
color: var(--primary);
line-height: 1;
margin-bottom: 4px;
}
[data-theme="dark"] .dashboard-kpi-value {
color: var(--accent);
}
.dashboard-kpi-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
}
.dashboard-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.dashboard-chart-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.dashboard-chart-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--primary);
}
[data-theme="dark"] .dashboard-chart-header {
color: var(--accent);
}
.dashboard-chart-body {
padding: 16px;
display: flex;
justify-content: center;
align-items: center;
min-height: 220px;
}
.dashboard-chart-body svg {
max-width: 100%;
height: auto;
}
.dashboard-empty-chart {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
gap: 8px;
text-align: center;
padding: 20px;
}
.dashboard-empty-chart span {
font-size: 32px;
opacity: 0.4;
}
.dashboard-footer-stats {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.dashboard-footer-stats .stat-highlight {
font-weight: 700;
color: var(--primary);
}
[data-theme="dark"] .dashboard-footer-stats .stat-highlight {
color: var(--accent);
}
.d3-tooltip {
position: fixed;
background: var(--primary-dark);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
pointer-events: none;
z-index: 3000;
display: none;
font-family: "Vazirmatn", sans-serif;
}
.d3-tooltip.visible {
display: block;
}
.rb-toolbar {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
padding: 8px 12px;
background: linear-gradient(
135deg,
var(--primary-dark),
var(--primary)
);
border-bottom: 1px solid var(--border);
}
.rb-toolbar .btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 11px;
padding: 5px 10px;
}
.rb-toolbar .btn:hover {
background: var(--accent);
color: var(--primary-dark);
}
.rb-toolbar .btn.btn-accent {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: var(--primary-dark);
font-weight: 700;
}
.rb-toolbar .btn.btn-danger {
background: linear-gradient(135deg, #f56565, #e53e3e);
color: #fff;
}
.rb-toolbar-separator {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
/* ✅ NEW: استایل جستجو و فیلتر در نوار ابزار بردها */
.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;
}
/* ✅ NEW: نوار فیلتر تگ‌ها */
.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;
}
/* ═══════════════════════════════════════════════════
✅ NEW: استایل‌های Drag & Drop و فیلتر
═══════════════════════════════════════════════════ */
.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;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
</style>
</head>
<body>
<header class="app-header">
<div class="app-brand">
<div class="app-logo">📊</div>
<div class="app-brand-text">
<div class="app-title" id="headerTitle">دستیار انتشار</div>
<div class="app-subtitle" id="headerSubtitle">
مدیریت نسخه‌ها و نیازمندی‌های محصولات
</div>
</div>
</div>
</header>
<div class="tabs-bar" id="advancedToolbar">
<div class="toolbar-controls">
<button class="toolbar-icon-btn" id="sidebarToggle" title="سایدبار">
<span id="sidebarIcon">✕</span>
</button>
<button class="toolbar-icon-btn" id="themeToggle" title="تم">
<span id="themeIcon">🌙</span>
</button>
<button class="toolbar-icon-btn" id="undoBtn" title="Ctrl+Z" disabled>
↩
</button>
<button class="toolbar-icon-btn" id="redoBtn" title="Ctrl+Y" disabled>
↪
</button>
</div>
<div class="toolbar-search">
<span style="position: absolute; right: 10px; opacity: 0.7">🔍</span>
<input
type="text"
class="search-input"
id="toolbarSearch"
placeholder="جستجو..."
/>
</div>
<div class="toolbar-filters">
<div class="filter-dropdown">
<span>🏷️</span>
<select class="filter-select" id="filterType">
<option value="">همه انواع</option>
<option value="backend">بک‌اند</option>
<option value="frontend">فرانت‌اند</option>
<option value="service">میکروسرویس</option>
<option value="database">پایگاه داده</option>
<option value="mobile">موبایل</option>
<option value="infrastructure">زیرساخت</option>
</select>
</div>
<div class="filter-dropdown">
<span>🌐</span>
<select class="filter-select" id="filterEnvironment">
<option value="">همه محیط‌ها</option>
</select>
</div>
</div>
<div class="toolbar-stats">
<div class="stat-item">
📋 <span class="stat-value" id="statTotal">0</span>
</div>
<div class="stat-item">
📦 <span class="stat-value" id="statProjects">0</span>
</div>
<div class="stat-item">
🌿 <span class="stat-value" id="statSubProjects">0</span>
</div>
<div class="stat-item">
🔗 <span class="stat-value" id="statDependencies">0</span>
</div>
</div>
</div>
<div class="app-main">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">🛠️ ابزارها</div>
</div>
<div class="sidebar-body" id="sidebarBody"></div>
</aside>
<main class="board-area" id="boardArea"></main>
</div>
<footer class="app-footer" id="appFooter">
<div>
<span class="footer-brand">طراح و توسعه‌دهنده:</span> هادی خزاعی اصل
<span style="margin: 0 10px">|</span
><span class="footer-brand">شرکت:</span> رایان پرداز کاوش
</div>
<div><span class="footer-brand">نسخه:</span> 1.2.0</div>
</footer>
<!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">
<span>⚙️</span><span>تنظیمات محصول</span>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="readonly-info">
ℹ️ اطلاعات پایه محصول فقط قابل مشاهده هستند.
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>عنوان محصول
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingProjectTitle"
readonly
/>
</div>
<div class="form-group">
<label class="form-label"
>نسخه <span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingVersion"
readonly
/>
</div>
</div>
<div class="form-group">
<label class="form-label"
>توضیحات <span class="readonly-badge">فقط خواندنی</span></label
><textarea
class="form-textarea"
id="settingDescription"
readonly
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>توسعه‌دهنده
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingDeveloper"
readonly
/>
</div>
<div class="form-group">
<label class="form-label"
>شرکت <span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="settingCompany"
readonly
/>
</div>
</div>
<div class="webhook-section">
<div class="webhook-section-header">
<div class="webhook-section-title">🔗 تنظیمات وب‌هوک</div>
<div class="webhook-toggle">
<span>فعال‌سازی</span
><label class="switch"
><input type="checkbox" id="settingWebhookEnabled" /><span
class="slider"
></span
></label>
</div>
</div>
<div class="form-group">
<label class="form-label"
>آدرس URL وب‌هوک <span class="required">*</span></label
><input
type="url"
class="form-input"
id="settingWebhookUrl"
dir="ltr"
/>
</div>
<div class="form-group">
<label class="form-label"
>شناسه کلاینت <span class="required">*</span></label
><input
type="text"
class="form-input"
id="settingWebhookClientId"
dir="ltr"
/>
</div>
<div class="webhook-status">
<span class="webhook-status-dot" id="webhookStatusDot"></span
><span id="webhookStatusText">وضعیت: غیرفعال</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveSettingsBtn">💾 ذخیره</button>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Environment Modals -->
<div class="modal-overlay" id="envManagerModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">🌍 افزودن محیط عملیاتی جدید</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نام محیط <span class="required">*</span></label
><input type="text" class="form-input" id="newEnvName" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><input type="text" class="form-input" id="newEnvDesc" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">رنگ پس‌زمینه</label>
<div class="color-input-wrapper">
<input type="color" id="newEnvBgColor" value="#38a169" /><span
class="color-preview-text"
id="newEnvBgColorText"
>#38a169</span
>
</div>
</div>
<div class="form-group">
<label class="form-label">رنگ متن</label>
<div class="color-input-wrapper">
<input type="color" id="newEnvTextColor" value="#ffffff" /><span
class="color-preview-text"
id="newEnvTextColorText"
>#ffffff</span
>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveNewEnvBtn">💾 ثبت</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="envEditModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش محیط عملیاتی</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
><input type="text" class="form-input" id="editEnvId" readonly />
</div>
<div class="form-group">
<label class="form-label">نام <span class="required">*</span></label
><input type="text" class="form-input" id="editEnvName" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><input type="text" class="form-input" id="editEnvDesc" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">رنگ پس‌زمینه</label>
<div class="color-input-wrapper">
<input type="color" id="editEnvBgColor" /><span
class="color-preview-text"
id="editEnvBgColorText"
></span>
</div>
</div>
<div class="form-group">
<label class="form-label">رنگ متن</label>
<div class="color-input-wrapper">
<input type="color" id="editEnvTextColor" /><span
class="color-preview-text"
id="editEnvTextColorText"
></span>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">پیش‌نمایش</label>
<div id="editEnvPreview" class="env-color-preview">نمونه</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveEnvEditBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Product Modals -->
<div class="modal-overlay" id="projectDetailModal">
<div class="modal modal-xl">
<div class="modal-header">
<div class="modal-title" id="projectDetailTitle">📦 جزئیات محصول</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body" id="projectDetailBody"></div>
<div class="modal-footer">
<button class="btn btn-danger btn-sm" id="projectDetailDeleteBtn">
🗑️ حذف محصول</button
><button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<div class="modal-overlay" id="productEditModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش محصول</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="editProductId"
readonly
/>
</div>
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
><input type="text" class="form-input" id="editProductTitle" />
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="editProductDescription"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">مالک</label
><input type="text" class="form-input" id="editProductOwner" />
</div>
<div class="form-group">
<label class="form-label"
>تاریخ ایجاد
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="editProductCreatedAt"
readonly
/>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveProductEditBtn">
💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="addProductModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">➕ ایجاد محصول جدید</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
><input type="text" class="form-input" id="newProductTitle" />
<div class="form-error" id="newProductTitleError">
عنوان محصول الزامی است و باید حداقل ۳ کاراکتر باشد
</div>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="newProductDescription"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">مالک</label
><input type="text" class="form-input" id="newProductOwner" />
</div>
<div class="form-group">
<label class="form-label"
>تاریخ ایجاد
<span class="readonly-badge">فقط خواندنی</span></label
><input
type="text"
class="form-input"
id="newProductCreatedAt"
readonly
/>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveNewProductBtn">💾 ثبت</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="addProjectModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">➕ افزودن پروژه جدید</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
><input type="text" class="form-input" id="newProjectTitle" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نوع <span class="required">*</span></label
><select class="form-select" id="newProjectType">
<option value="">انتخاب کنید...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">فریم‌ورک</label
><input type="text" class="form-input" id="newProjectFramework" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">زبان</label
><input type="text" class="form-input" id="newProjectLanguage" />
</div>
<div class="form-group">
<label class="form-label">آدرس مخزن</label
><input
type="text"
class="form-input"
id="newProjectRepo"
dir="ltr"
/>
</div>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="newProjectDescription"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveNewProjectBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<div class="modal-overlay" id="editProjectModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">✏️ ویرایش پروژه</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label"
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
><input type="text" class="form-input" id="editProjId" readonly />
</div>
<div class="form-group">
<label class="form-label"
>عنوان <span class="required">*</span></label
><input type="text" class="form-input" id="editProjTitle" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نوع <span class="required">*</span></label
><select class="form-select" id="editProjType">
<option value="">انتخاب کنید...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">فریم‌ورک</label
><input type="text" class="form-input" id="editProjFramework" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">زبان</label
><input type="text" class="form-input" id="editProjLanguage" />
</div>
<div class="form-group">
<label class="form-label">آدرس مخزن</label
><input
type="text"
class="form-input"
id="editProjRepo"
dir="ltr"
/>
</div>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label
><textarea
class="form-textarea"
id="editProjDescription"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveProjEditBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Deployment Modal -->
<div class="modal-overlay" id="deploymentModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title" id="deploymentModalTitle">
🚀 افزودن نسخه استقرار
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="form-group" id="deploymentEnvGroup"></div>
<div class="form-row">
<div class="form-group">
<label class="form-label"
>نسخه <span class="required">*</span></label
><input
type="text"
class="form-input"
id="depVersion"
dir="ltr"
placeholder="مثلاً: 1.2.3"
/>
<div class="form-hint">از فرمت SemVer استفاده کنید</div>
</div>
<div class="form-group">
<label class="form-label"
>تاریخ و زمان استقرار <span class="required">*</span></label
><input
type="datetime-local"
class="form-input"
id="depDate"
dir="ltr"
/>
</div>
</div>
<div class="form-group">
<label class="form-label"
>ریلیز نوت <span class="required">*</span></label
><textarea
class="form-textarea"
id="depReleaseNotes"
rows="3"
placeholder="توضیحات تغییرات این نسخه..."
></textarea>
<div class="form-hint">
شرح تغییرات، باگ‌فیکس‌ها و ویژگی‌های جدید این نسخه
</div>
</div>
<div id="requiredDeploymentsSection"></div>
</div>
<div class="modal-footer">
<button class="btn btn-accent" id="saveDeploymentBtn">💾 ذخیره</button
><button class="btn btn-ghost" data-close-modal>انصراف</button>
</div>
</div>
</div>
<!-- Dependency Manager Modal -->
<div class="modal-overlay" id="dependencyManagerModal">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">🔗 مدیریت وابستگی‌ها</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body">
<div class="readonly-info">
ℹ️ هر وابستگی فقط شامل یک پروژه است. انتخاب نسخه و محیط در زمان ثبت
استقرار انجام می‌شود.
</div>
<div class="dependency-section-title">📋 وابستگی‌های فعلی</div>
<div id="currentDependenciesList" class="dependency-list"></div>
<div class="dependency-search-box">
<div class="dependency-section-title">🔍 افزودن وابستگی جدید</div>
<input
type="text"
class="dependency-search-input"
id="dependencySearch"
placeholder="جستجو در پروژه‌ها..."
/>
<div
id="dependencySearchResults"
class="dependency-search-results"
></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Galaxy Chart Modal -->
<div class="modal-overlay" id="galaxyChartModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🌌 نمودار کهکشانی محصولات</div>
<div class="galaxy-toolbar">
<div class="galaxy-search">
<span class="galaxy-search-icon">🔍</span>
<input
type="text"
id="galaxySearchInput"
placeholder="جستجو در نمودار..."
autocomplete="off"
/>
<button
class="galaxy-search-clear"
id="galaxySearchClear"
title="پاک کردن"
>
✕
</button>
</div>
<button
class="btn btn-sm"
id="galaxyCollapseAll"
title="کولاپس همه محصولات"
>
⊟ کولاپس همه
</button>
<button
class="btn btn-sm"
id="galaxyExpandAll"
title="باز کردن همه محصولات"
>
⊞ باز کردن همه
</button>
<button class="btn btn-sm" id="galaxyResetView">🎯 بازنشانی</button>
<button class="btn btn-sm" id="galaxyExportPNG">📷 PNG</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body galaxy-modal-body">
<div id="galaxyChartContainer" class="galaxy-chart-container"></div>
<div class="galaxy-search-results" id="galaxySearchResults"></div>
<div class="galaxy-legend" id="galaxyLegend"></div>
<div class="galaxy-info-panel" id="galaxyInfoPanel">
<button
class="modal-close"
id="galaxyInfoClose"
style="
position: absolute;
top: 8px;
left: 8px;
width: 22px;
height: 22px;
font-size: 12px;
"
>
✕
</button>
<div id="galaxyInfoContent"></div>
</div>
<div class="galaxy-zoom-indicator" id="galaxyZoomIndicator">
🔍 100%
</div>
</div>
<div class="modal-footer">
<div class="galaxy-stats" id="galaxyStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Executive Dashboard Modal -->
<div class="modal-overlay" id="executiveDashboardModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">📊 داشبورد مدیریتی</div>
<div class="galaxy-toolbar">
<button class="btn btn-sm" id="dashboardRefresh">
🔄 به‌روزرسانی
</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div id="dashboardContainer" class="dashboard-container"></div>
</div>
<div class="modal-footer">
<div class="dashboard-footer-stats" id="dashboardFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Dependency Analysis Modal -->
<div class="modal-overlay" id="dependencyAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🔗 تحلیل وابستگی‌ها</div>
<div class="galaxy-toolbar">
<button class="btn btn-sm" id="depAnalysisRefresh">🔄</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div
id="dependencyAnalysisContainer"
class="dashboard-container"
></div>
</div>
<div class="modal-footer">
<div class="dashboard-footer-stats" id="depAnalysisFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Deployment Analysis Modal -->
<div class="modal-overlay" id="deploymentAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🚀 تحلیل استقرار</div>
<div class="galaxy-toolbar">
<button class="btn btn-sm" id="deployAnalysisRefresh">🔄</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div
id="deploymentAnalysisContainer"
class="dashboard-container"
></div>
</div>
<div class="modal-footer">
<div
class="dashboard-footer-stats"
id="deployAnalysisFooterStats"
></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Version Analysis Modal -->
<div class="modal-overlay" id="versionAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🔢 تحلیل نسخه</div>
<div class="galaxy-toolbar">
<button class="btn btn-sm" id="verAnalysisRefresh">🔄</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div id="versionAnalysisContainer" class="dashboard-container"></div>
</div>
<div class="modal-footer">
<div class="dashboard-footer-stats" id="verAnalysisFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Risk Analysis Modal -->
<div class="modal-overlay" id="riskAnalysisModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">⚠️ تحلیل ریسک</div>
<div class="galaxy-toolbar">
<button class="btn btn-sm" id="riskAnalysisRefresh">🔄</button>
</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<div class="modal-body dashboard-modal-body">
<div id="riskAnalysisContainer" class="dashboard-container"></div>
</div>
<div class="modal-footer">
<div
class="dashboard-footer-stats"
id="riskAnalysisFooterStats"
></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</div>
</div>
</div>
<!-- Release Boards Modal -->
<div class="modal-overlay" id="releaseBoardsModal">
<div class="modal modal-galaxy">
<div class="modal-header">
<div class="modal-title">🚀 بردهای انتشار</div>
<button class="modal-close" data-close-modal>✕</button>
</div>
<!-- ✅ Toolbar با جستجو و فیلتر تگ -->
<div class="rb-toolbar">
<button class="btn" id="rbEditBoard" title="ویرایش برد فعلی">
✏️ ویرایش
</button>
<button class="btn" id="rbManageTags" title="مدیریت تگ‌ها">
🏷️ تگ‌ها
</button>
<div class="rb-toolbar-separator"></div>
<!-- ✅ NEW: جستجوی کارت‌ها -->
<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>
<!-- ✅ NEW: نوار فیلتر تگ‌ها -->
<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">
<div class="dashboard-footer-stats" id="rbFooterStats"></div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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: #edf2f7; color: #1a202c"
>
📥 بک لاگ
</div>
<div
class="rb-preview-column"
style="background: #ebf8ff; color: #1a202c"
>
📋 برای انجام
</div>
<div
class="rb-preview-column"
style="background: #fef5e7; color: #1a202c"
>
🔨 در حال انجام
</div>
<div
class="rb-preview-column"
style="background: #faf5ff; color: #1a202c"
>
🔍 برای تست
</div>
<div
class="rb-preview-column"
style="background: #f0fff4; color: #1a202c"
>
✅ انجام شده
</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="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>
<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">
<div style="font-size: 11px; color: var(--text-muted)">
💡 تغییرات تگ‌ها به صورت خودکار در تمام کارت‌ها اعمال می‌شود
</div>
<button class="btn btn-ghost" data-close-modal>بستن</button>
</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="
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
border: 1px solid #90cdf4;
border-right: 4px solid var(--info);
padding: 14px 18px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
line-height: 1.8;
"
>
<div
style="
font-weight: 700;
font-size: 14px;
margin-bottom: 8px;
color: var(--primary);
"
>
⚠️ داده ذخیره‌شده خودکار یافت شد
</div>
<div style="color: var(--text-muted)">
به نظر می‌رسد در جلسه قبلی، تغییراتی در برنامه اعمال شده و به صورت
خودکار ذخیره شده‌اند.
</div>
<div
style="
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
"
>
📅 تاریخ ذخیره:
<strong id="rbAutosaveDate" style="color: var(--primary)"
>—</strong
>
</div>
</div>
<div
style="
background: var(--border-light);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
"
>
<div
style="
font-weight: 700;
font-size: 13px;
margin-bottom: 10px;
color: var(--primary);
"
>
📊 محتویات داده‌های ذخیره‌شده:
</div>
<div
style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
"
>
<div
style="
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--card-bg);
border-radius: 6px;
"
>
<span>🌍 محیط‌ها:</span
><strong id="rbAutosaveEnvCount" style="color: var(--primary)"
>0</strong
>
</div>
<div
style="
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--card-bg);
border-radius: 6px;
"
>
<span>📦 محصولات:</span
><strong id="rbAutosaveProdCount" style="color: var(--primary)"
>0</strong
>
</div>
<div
style="
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--card-bg);
border-radius: 6px;
"
>
<span>🌿 پروژه‌ها:</span
><strong id="rbAutosaveProjCount" style="color: var(--primary)"
>0</strong
>
</div>
<div
style="
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--card-bg);
border-radius: 6px;
"
>
<span>🚀 استقرارها:</span
><strong id="rbAutosaveDepCount" style="color: var(--primary)"
>0</strong
>
</div>
<div
style="
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--card-bg);
border-radius: 6px;
"
>
<span>📋 بردها:</span
><strong id="rbAutosaveBoardCount" style="color: var(--primary)"
>0</strong
>
</div>
<div
style="
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--card-bg);
border-radius: 6px;
"
>
<span>🏷️ تگ‌های برد:</span
><strong id="rbAutosaveTagCount" style="color: var(--primary)"
>0</strong
>
</div>
</div>
</div>
<div style="font-size: 13px; color: var(--text); line-height: 1.8">
آیا می‌خواهید این داده‌ها را بازیابی کنید؟
<ul
style="
margin-top: 8px;
padding-right: 20px;
color: var(--text-muted);
font-size: 12px;
"
>
<li>
<strong>بازیابی:</strong> تمام داده‌های ذخیره‌شده جایگزین
داده‌های فعلی می‌شوند
</li>
<li>
<strong>نادیده گرفتن:</strong> داده‌های خودکار حذف شده و
داده‌های فعلی حفظ می‌شوند
</li>
</ul>
</div>
</div>
<div class="modal-footer" style="justify-content: space-between">
<button class="btn btn-accent" id="rbRecoverAutosaveBtn">
💾 بازیابی
</button>
<button class="btn btn-danger" id="rbDiscardAutosaveBtn">
🗑️ نادیده گرفتن
</button>
</div>
</div>
</div>
<!-- Auto-save Indicator -->
<div
id="rbAutosaveIndicator"
style="
position: fixed;
bottom: 50px;
left: 20px;
background: var(--panel-bg);
color: var(--text);
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
z-index: 1500;
pointer-events: none;
"
>
<div
style="
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: rbPulse 2s infinite;
"
></div>
<span id="rbAutosaveText">ذخیره خودکار انجام شد</span>
</div>
<style>
@keyframes rbPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style>
<div class="toast-container" id="toastContainer"></div>
<div class="galaxy-tooltip" id="galaxyTooltip"></div>
<div class="d3-tooltip" id="d3Tooltip"></div>
<script>
/* ==================== DATA ==================== */
const projectData = {
projectId: "550e8400-e29b-41d4-a716-446655440000",
projectName: "xReleaseAssistant",
projectTitle: "دستیار انتشار",
description: "مدیریت نسخه‌ها و نیازمندی‌های محصولات",
version: "1.2.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: "مشاهده و مدیریت بردهای انتشار",
},
/* ✅ NEW: ابزار مدیریت تگ‌ها */
{
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: [
{
id: "env-prod-001",
name: "Production",
description: "محیط عملیاتی اصلی - در دسترس کاربران نهایی",
backgroundColor: "#38a169",
textColor: "#ffffff",
},
{
id: "env-stage-002",
name: "Staging",
description: "محیط پیش از تولید - شبیه‌سازی Production",
backgroundColor: "#dd6b20",
textColor: "#ffffff",
},
{
id: "env-dev-003",
name: "Development",
description: "محیط توسعه - برای تیم فنی",
backgroundColor: "#3182ce",
textColor: "#ffffff",
},
{
id: "env-qa-004",
name: "QA",
description: "محیط تست کیفیت - کنترل کیفیت خودکار و دستی",
backgroundColor: "#805ad5",
textColor: "#ffffff",
},
{
id: "env-uat-005",
name: "UAT",
description: "تست پذیرش کاربر - تأیید کارفرما",
backgroundColor: "#d69e2e",
textColor: "#ffffff",
},
{
id: "env-dr-006",
name: "Disaster Recovery",
description: "محیط بازیابی پس از بحران - پشتیبان Production",
backgroundColor: "#e53e3e",
textColor: "#ffffff",
},
],
products: [],
boards: [],
boardTags: [],
},
};
const VALID_PROJECT_TYPES = [
{ value: "backend", label: "بک‌اند (Backend)", icon: "🖥️" },
{ value: "frontend", label: "فرانت‌اند (Frontend)", icon: "🌐" },
{ value: "service", label: "میکروسرویس (Service)", icon: "⚙️" },
{ value: "database", label: "پایگاه داده (Database)", icon: "🗄️" },
{ value: "mobile", label: "موبایل (Mobile)", icon: "📱" },
{
value: "infrastructure",
label: "زیرساخت (Infrastructure)",
icon: "🏗️",
},
];
/* ==================== HELPERS ==================== */
function validateAppDataStructure(d) {
return (
d &&
typeof d === "object" &&
Array.isArray(d.environments) &&
Array.isArray(d.products)
);
}
function populateProjectTypeDropdowns() {
["newProjectType", "editProjType"].forEach((id) => {
const s = document.getElementById(id);
if (!s) return;
const cv = s.value;
s.innerHTML =
'<option value="">انتخاب کنید...</option>' +
VALID_PROJECT_TYPES.map(
(t) => `<option value="${t.value}">${t.icon} ${t.label}</option>`,
).join("");
if (cv) s.value = cv;
});
}
function showToast(msg, type = "info", dur = 3000) {
const c = document.getElementById("toastContainer");
const t = document.createElement("div");
t.className = `toast ${type}`;
const ic = { success: "✅", error: "❌", warning: "⚠️", info: "ℹ️" };
t.innerHTML = `<span>${ic[type] || "ℹ️"}</span><span>${msg}</span>`;
c.appendChild(t);
setTimeout(() => {
t.style.opacity = "0";
setTimeout(() => t.remove(), 300);
}, dur);
}
function showD3Tooltip(e, text) {
let tt = document.getElementById("d3Tooltip");
if (!tt) return;
tt.textContent = text;
tt.classList.add("visible");
tt.style.left = e.clientX + 12 + "px";
tt.style.top = e.clientY + 12 + "px";
}
function hideD3Tooltip() {
const tt = document.getElementById("d3Tooltip");
if (tt) tt.classList.remove("visible");
}
function refreshUI() {
ToolbarManager.updateStats();
BoardRenderer.render();
}
/* ==================== MODAL MANAGER ==================== */
const ModalManager = {
stack: [],
open(id) {
const m = document.getElementById(id);
if (!m) return;
this.stack = this.stack.filter((x) => x !== id);
this.stack.push(id);
this.stack.forEach((mid, i) => {
const el = document.getElementById(mid);
if (!el) return;
if (mid === id) el.classList.add("active");
el.style.zIndex = 1000 + i + 1;
});
},
close(id) {
const m = document.getElementById(id);
if (!m) return;
m.classList.remove("active");
m.style.zIndex = "";
this.stack = this.stack.filter((x) => x !== id);
this.stack.forEach((mid, i) => {
const el = document.getElementById(mid);
if (el) el.style.zIndex = 1000 + i + 1;
});
},
closeTop() {
if (this.stack.length > 0)
this.close(this.stack[this.stack.length - 1]);
},
init() {
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const m = e.target.closest(".modal-overlay");
if (m && m.id) this.close(m.id);
});
});
document.querySelectorAll(".modal-overlay").forEach((ov) => {
ov.addEventListener("click", (e) => {
if (e.target === ov && ov.id) this.close(ov.id);
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeTop();
}
});
},
};
/* ==================== TOOLBAR MANAGER ==================== */
const ToolbarManager = {
currentFilters: { query: "", typeFilter: "", envFilter: "" },
init() {
this.populateEnvironmentFilter();
this.bindEvents();
this.updateStats();
},
populateEnvironmentFilter() {
const s = document.getElementById("filterEnvironment");
if (!s) return;
s.innerHTML =
'<option value="">همه محیط‌ها</option>' +
projectData.appData.environments
.map((e) => `<option value="${e.id}">${e.name}</option>`)
.join("");
},
bindEvents() {
const si = document.getElementById("toolbarSearch"),
ft = document.getElementById("filterType"),
fe = document.getElementById("filterEnvironment");
if (si)
si.addEventListener("input", () => {
this.applyFilters();
refreshUI();
});
if (ft)
ft.addEventListener("change", () => {
this.applyFilters();
refreshUI();
});
if (fe)
fe.addEventListener("change", () => {
this.applyFilters();
refreshUI();
});
},
applyFilters() {
this.currentFilters = {
query: (document.getElementById("toolbarSearch")?.value || "")
.trim()
.toLowerCase(),
typeFilter: document.getElementById("filterType")?.value || "",
envFilter:
document.getElementById("filterEnvironment")?.value || "",
};
this.updateStats();
},
updateStats() {
const ps = projectData.appData.products || [];
let tp = 0,
td = 0;
ps.forEach((p) => {
(p.projects || []).forEach((pr) => {
tp++;
td += (pr.dependencies || []).length;
});
});
const s = (id, v) => {
const el = document.getElementById(id);
if (el) el.textContent = v;
};
s("statTotal", ps.length + tp);
s("statProjects", ps.length);
s("statSubProjects", tp);
s("statDependencies", td);
},
shouldShowProduct(product) {
const f = this.currentFilters;
if (!f.query && !f.typeFilter && !f.envFilter) return true;
return (product.projects || []).some((pr) => {
const mq =
!f.query ||
pr.title.toLowerCase().includes(f.query) ||
product.title.toLowerCase().includes(f.query);
const mt = !f.typeFilter || pr.type === f.typeFilter;
const me =
!f.envFilter ||
(pr.deployments || [])
.filter((d) => d.status === "active")
.some((d) => d.environmentId === f.envFilter);
return mq && mt && me;
});
},
};
/* ==================== HISTORY MANAGER ==================== */
const HistoryManager = {
undoStack: [],
redoStack: [],
maxSize: 50,
isUndoRedoInProgress: false,
_debounceTimer: null,
_pendingState: null,
pushState() {
if (this.isUndoRedoInProgress) return;
this._pendingState = projectData.appData;
if (this._debounceTimer) clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this._commitState();
}, 100);
},
_commitState() {
if (!this._pendingState) return;
let snapshot;
if (typeof structuredClone === "function")
snapshot = structuredClone(this._pendingState);
else snapshot = JSON.parse(JSON.stringify(this._pendingState));
this.undoStack.push(snapshot);
if (this.undoStack.length > this.maxSize) this.undoStack.shift();
this.redoStack = [];
this._pendingState = null;
this.updateButtons();
},
undo() {
if (this.undoStack.length === 0) {
showToast("هیچ تغییری نیست", "warning");
return;
}
if (this._pendingState) this._commitState();
this.redoStack.push(
structuredClone
? structuredClone(projectData.appData)
: JSON.parse(JSON.stringify(projectData.appData)),
);
this.isUndoRedoInProgress = true;
projectData.appData = this.undoStack.pop();
this.isUndoRedoInProgress = false;
this.updateUI();
this.updateButtons();
showToast("↩️ بازگشت", "success");
},
redo() {
if (this.redoStack.length === 0) {
showToast("هیچ تغییری نیست", "warning");
return;
}
if (this._pendingState) this._commitState();
this.undoStack.push(
structuredClone
? structuredClone(projectData.appData)
: JSON.parse(JSON.stringify(projectData.appData)),
);
this.isUndoRedoInProgress = true;
projectData.appData = this.redoStack.pop();
this.isUndoRedoInProgress = false;
this.updateUI();
this.updateButtons();
showToast("↪️ باز انجام", "success");
},
updateUI() {
refreshUI();
if (ProductManager.currentDetailProductId)
ProductManager.showDetail(ProductManager.currentDetailProductId);
},
updateButtons() {
const u = document.getElementById("undoBtn"),
r = document.getElementById("redoBtn");
if (u) u.disabled = this.undoStack.length === 0;
if (r) r.disabled = this.redoStack.length === 0;
},
init() {
this.pushState();
document
.getElementById("undoBtn")
.addEventListener("click", () => this.undo());
document
.getElementById("redoBtn")
.addEventListener("click", () => this.redo());
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
this.undo();
}
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "y" || (e.key === "z" && e.shiftKey))
) {
e.preventDefault();
this.redo();
}
});
},
};
/* ==================== SECTION COLLAPSE ==================== */
const SectionCollapseManager = {
storageKey: "boardSectionCollapseState",
getState() {
try {
const s = localStorage.getItem(this.storageKey);
return s
? JSON.parse(s)
: { dashboard: false, products: false, environments: false };
} catch {
return { dashboard: false, products: false, environments: false };
}
},
saveState(s) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(s));
} catch (e) {}
},
toggle(id) {
const s = this.getState();
s[id] = !s[id];
this.saveState(s);
const sec = document.getElementById(`section-${id}`);
if (sec) sec.classList.toggle("collapsed", s[id]);
return s[id];
},
init() {
const s = this.getState();
Object.keys(s).forEach((id) => {
const sec = document.getElementById(`section-${id}`);
if (sec && s[id]) sec.classList.add("collapsed");
});
document
.getElementById("boardArea")
.addEventListener("click", (e) => {
const ht = e.target.closest(".board-header-title");
if (ht) {
const sec = ht.closest(".board-section");
if (sec) this.toggle(sec.id.replace("section-", ""));
}
});
},
};
/* ==================== DASHBOARD SECTION ==================== */
const DashboardSectionRenderer = {
getSectionHTML() {
const d = this.getData();
let h = `<div class="board-section" id="section-dashboard">`;
h += `<div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📊 داشبورد مدیریتی</div><div class="board-header-actions"><button class="btn btn-accent btn-sm" onclick="DashboardManager.open()">📊 مشاهده کامل</button></div></div>`;
h += `<div class="section-content">`;
h += '<div class="dashboard-kpi-row" style="margin-bottom:16px">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژه‌ها</div><div class="dashboard-chart-body" id="mainDashTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="mainDashEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژه‌ها</div><div class="dashboard-chart-body" id="mainDashStatusChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژه‌ها به تفکیک محصول</div><div class="dashboard-chart-body" id="mainDashProductChart"></div></div>';
h += "</div>";
h += "</div></div>";
return h;
},
getData() {
const ps = projectData.appData.products || [];
let tp = 0,
td = 0,
tdp = 0;
const tc = {},
edc = {},
ppc = [];
let pwd = 0,
pwod = 0;
ps.forEach((prod) => {
const projects = prod.projects || [];
ppc.push({ title: prod.title, count: projects.length });
projects.forEach((pr) => {
tp++;
td += (pr.dependencies || []).length;
const active = (pr.deployments || []).filter(
(d) => d.status === "active",
);
tdp += active.length;
if (active.length > 0) pwd++;
else pwod++;
tc[pr.type] = (tc[pr.type] || 0) + 1;
active.forEach((d) => {
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
edc[envName] = (edc[envName] || 0) + 1;
});
});
});
return {
products: ps.length,
projects: tp,
deps: td,
deploys: tdp,
environments: projectData.appData.environments.length,
tc,
edc,
ppc,
pwd,
pwod,
};
},
renderCharts() {
const d = this.getData();
this.renderDonut(
"mainDashTypeChart",
Object.entries(d.tc).map(([k, v]) => ({
label: this.getTypeLabel(k),
value: v,
color: this.getTypeColor(k),
})),
);
this.renderBar(
"mainDashEnvChart",
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
"#38a169",
);
this.renderDonut("mainDashStatusChart", [
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
]);
this.renderBar(
"mainDashProductChart",
d.ppc.map((p) => ({
label:
p.title.length > 15
? p.title.substring(0, 15) + "..."
: p.title,
value: p.count,
})),
"#3182ce",
);
},
renderDonut(cid, data) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 240,
hgt = 200,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((d) => d.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 6);
const total = data.reduce((s, d) => s + d.value, 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (d) => d.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).transition().duration(150).attr("d", arcH(d));
showD3Tooltip(
e,
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr("fill", "var(--text)")
.attr("font-size", "20px")
.attr("font-weight", "800")
.text(total);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.3em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.text("پروژه");
},
renderBar(cid, data, color) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 200,
m = { top: 20, right: 15, bottom: 40, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-20)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d) => x(d.label))
.attr("y", (d) => y(d.value))
.attr("width", x.bandwidth())
.attr("height", (d) => ih - y(d.value))
.attr("fill", color)
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d.label}: ${d.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", color);
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(data)
.join("text")
.attr("x", (d) => x(d.label) + x.bandwidth() / 2)
.attr("y", (d) => y(d.value) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.value);
},
getTypeLabel(t) {
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
return i ? i.label.split(" ")[0] : t;
},
getTypeColor(t) {
const c = {
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
};
return c[t] || "#718096";
},
};
/* ==================== ENVIRONMENT RENDERER ==================== */
const EnvironmentRenderer = {
getSectionHTML() {
const envs = projectData.appData.environments || [];
let h = `<div class="board-section" id="section-environments"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>🌍 محیط‌های عملیاتی (${envs.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="EnvironmentManager.openAddModal()">➕ محیط جدید</button></div></div><div class="section-content">`;
if (envs.length === 0) {
h +=
'<div class="board-empty"><div class="board-empty-icon">🌍</div><h2>هیچ محیطی نیست</h2></div>';
} else {
h += '<div class="env-card-grid">';
envs.forEach((env) => {
h += `<div class="env-card-item"><div class="env-card-preview" style="background-color:${env.backgroundColor || "#38a169"};color:${env.textColor || "#fff"}">${env.name}</div><div class="env-card-body"><div class="env-card-name">${env.name}</div><div class="env-card-desc">${env.description || ""}</div></div><div class="env-card-footer"><button class="btn btn-ghost btn-sm" onclick="EnvironmentManager.openEditModal('${env.id}')">✏️ ویرایش</button><button class="btn btn-danger btn-sm" onclick="EnvironmentManager.remove('${env.id}')">🗑️ حذف</button></div></div>`;
});
h += "</div>";
}
h += "</div></div>";
return h;
},
};
/* ==================== BOARD RENDERER ==================== */
const BoardRenderer = {
render() {
const board = document.getElementById("boardArea");
const products = projectData.appData.products || [];
const fp = products.filter((p) =>
ToolbarManager.shouldShowProduct(p),
);
let h = "";
h += DashboardSectionRenderer.getSectionHTML();
h += `<div class="board-section" id="section-products"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📦 محصولات (${fp.length} از ${products.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="ProductManager.openAddProductModal()">➕ محصول جدید</button></div></div><div class="section-content">`;
if (products.length === 0) {
h +=
'<div class="board-empty"><div class="board-empty-icon">📦</div><h2>هیچ محصولی نیست</h2></div>';
} else if (fp.length === 0) {
h += '<div class="no-results">🔍 نتیجه‌ای یافت نشد</div>';
} else {
h += '<div class="projects-grid">';
fp.forEach((prod) => {
const projects = prod.projects || [];
const tc = {};
projects.forEach((pr) => {
tc[pr.type] = (tc[pr.type] || 0) + 1;
});
const td = projects.reduce(
(s, pr) => s + (pr.dependencies || []).length,
0,
);
const tdp = projects.reduce(
(s, pr) =>
s +
(pr.deployments || []).filter((d) => d.status === "active")
.length,
0,
);
h += `<div class="project-card"><div class="project-card-header"><div class="project-card-title-row"><span style="font-size:22px">📦</span><div class="project-card-title">${prod.title}</div></div><div class="project-card-desc">${prod.description || ""}</div></div><div class="project-card-body"><div class="project-stat-row"><span>🌿 پروژه‌ها:</span><span class="project-stat-value">${projects.length}</span></div><div class="project-stat-row"><span>🔗 وابستگی‌ها:</span><span class="project-stat-value">${td}</span></div><div class="project-stat-row"><span>🚀 استقرارها:</span><span class="project-stat-value">${tdp}</span></div>${
Object.keys(tc).length > 0
? `<div class="project-type-badges">${Object.entries(tc)
.map(([type, count]) => {
const ti = VALID_PROJECT_TYPES.find(
(t) => t.value === type,
) || { label: type };
return `<span class="type-badge type-${type}">${ti.label}: ${count}</span>`;
})
.join("")}</div>`
: ""
}</div><div class="project-card-footer"><div class="card-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.showDetail('${prod.id}')">👁️ جزئیات</button></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeProduct('${prod.id}')">🗑️</button></div></div>`;
});
h += "</div>";
}
h += "</div></div>";
h += EnvironmentRenderer.getSectionHTML();
board.innerHTML = h;
const st = SectionCollapseManager.getState();
Object.keys(st).forEach((id) => {
const sec = document.getElementById(`section-${id}`);
if (sec && st[id]) sec.classList.add("collapsed");
});
setTimeout(() => {
DashboardSectionRenderer.renderCharts();
}, 100);
},
};
/* ==================== ACTION REGISTRY ==================== */
const ActionRegistry = {
openProductSettings() {
openSettingsModal();
},
saveProduct() {
projectData.metadata.updatedAt = new Date().toISOString();
const js = JSON.stringify(projectData.appData, null, 2);
const b = new Blob([js], { type: "application/json;charset=utf-8" });
const u = URL.createObjectURL(b);
const a = document.createElement("a");
a.href = u;
a.download = `${projectData.projectName}_v${projectData.version}_${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(u);
showToast("💾 ذخیره شد", "success");
},
loadProduct() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.addEventListener("change", (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = (ev) => {
try {
const ld = JSON.parse(ev.target.result);
if (!validateAppDataStructure(ld)) {
showToast("❌ ساختار نامعتبر", "error");
return;
}
HistoryManager.pushState();
projectData.appData = ld;
AutosaveManager.clear();
AutosaveManager.save();
updateProjectUI();
renderToolGroups();
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast("📂 بارگذاری شد", "success");
} catch (err) {
showToast("❌ خطا: " + err.message, "error");
}
};
r.readAsText(f);
});
inp.click();
},
clearAll() {
if (!confirm("⚠️ پاکسازی کامل؟ این عمل غیرقابل بازگشت است!")) return;
if (!confirm("⚠️ تأیید نهایی؟")) return;
HistoryManager.pushState();
projectData.appData = {
environments: [],
products: [],
boards: [],
boardTags: [],
};
AutosaveManager.clear();
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast("🗑️ پاکسازی شد", "success");
},
showGalaxyChart() {
GalaxyChartManager.open();
},
showExecutiveDashboard() {
DashboardManager.open();
},
showDependencyAnalysis() {
DependencyAnalysisManager.open();
},
showDeploymentAnalysis() {
DeploymentAnalysisManager.open();
},
showVersionAnalysis() {
VersionAnalysisManager.open();
},
showRiskAnalysis() {
RiskAnalysisManager.open();
},
showReleaseBoards() {
ReleaseBoardsManager.open();
},
/* ✅ NEW: اکشن مدیریت تگ‌ها */
showTagManager() {
ReleaseBoardsManager.openTagManager();
},
defaultAction(tool) {
showToast(`ابزار: ${tool.title}`, "info");
},
};
/* ==================== WEBHOOK MANAGER ==================== */
const WebhookManager = {
getConfig() {
return projectData.webhook;
},
setConfig(c) {
projectData.webhook = { ...projectData.webhook, ...c };
this.updateStatusUI();
},
isValid() {
const c = this.getConfig();
return (
c.enabled && c.url && c.clientId && /^https?:\/\//i.test(c.url)
);
},
updateStatusUI() {
const c = this.getConfig();
const d = document.getElementById("webhookStatusDot"),
t = document.getElementById("webhookStatusText");
if (!d || !t) return;
d.className = "webhook-status-dot";
if (!c.enabled) t.textContent = "وضعیت: غیرفعال";
else if (!c.url || !c.clientId) {
d.classList.add("pending");
t.textContent = "وضعیت: ناقص";
} else {
d.classList.add("success");
t.textContent = "وضعیت: آماده";
}
},
async sendEvent(en, payload = {}) {
if (!this.isValid()) return;
const c = this.getConfig();
try {
await fetch(c.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Client-Id": c.clientId,
"X-Event-Name": en,
},
body: JSON.stringify({
event: en,
timestamp: new Date().toISOString(),
projectId: projectData.projectId,
data: payload,
}),
});
} catch (e) {}
},
};
/* ==================== ENVIRONMENT MANAGER ==================== */
const EnvironmentManager = {
currentEditEnvId: null,
openAddModal() {
document.getElementById("newEnvName").value = "";
document.getElementById("newEnvDesc").value = "";
document.getElementById("newEnvBgColor").value = "#38a169";
document.getElementById("newEnvTextColor").value = "#ffffff";
ModalManager.open("envManagerModal");
},
saveNewEnv() {
const n = document.getElementById("newEnvName").value.trim();
if (!n) {
showToast("نام الزامی است", "error");
return;
}
if (
projectData.appData.environments.some(
(e) => e.name.toLowerCase() === n.toLowerCase(),
)
) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.environments.push({
id: `env-${Date.now()}`,
name: n,
description:
document.getElementById("newEnvDesc").value.trim() || "",
backgroundColor: document.getElementById("newEnvBgColor").value,
textColor: document.getElementById("newEnvTextColor").value,
});
ModalManager.close("envManagerModal");
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast(`محیط "${n}" اضافه شد`, "success");
},
openEditModal(envId) {
const env = projectData.appData.environments.find(
(e) => e.id === envId,
);
if (!env) return;
this.currentEditEnvId = envId;
document.getElementById("editEnvId").value = env.id;
document.getElementById("editEnvName").value = env.name;
document.getElementById("editEnvDesc").value = env.description || "";
document.getElementById("editEnvBgColor").value =
env.backgroundColor || "#38a169";
document.getElementById("editEnvTextColor").value =
env.textColor || "#ffffff";
this.updateEditPreview();
ModalManager.open("envEditModal");
},
updateEditPreview() {
const p = document.getElementById("editEnvPreview");
p.style.backgroundColor =
document.getElementById("editEnvBgColor").value;
p.style.color = document.getElementById("editEnvTextColor").value;
},
saveEdit() {
const env = projectData.appData.environments.find(
(e) => e.id === this.currentEditEnvId,
);
if (!env) return;
const nn = document.getElementById("editEnvName").value.trim();
if (!nn) {
showToast("نام الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
env.name = nn;
env.description = document.getElementById("editEnvDesc").value.trim();
env.backgroundColor = document.getElementById("editEnvBgColor").value;
env.textColor = document.getElementById("editEnvTextColor").value;
ModalManager.close("envEditModal");
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast(`محیط "${env.name}" ویرایش شد`, "success");
},
remove(id) {
const env = projectData.appData.environments.find((e) => e.id === id);
if (!env) return;
if (!confirm(`حذف "${env.name}"؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.environments =
projectData.appData.environments.filter((e) => e.id !== id);
ToolbarManager.populateEnvironmentFilter();
refreshUI();
showToast(`"${env.name}" حذف شد`, "warning");
},
};
/* ==================== PRODUCT MANAGER ==================== */
const ProductManager = {
currentDetailProductId: null,
currentEditProjectId: null,
currentDependencyProjectId: null,
currentDependencyProjId: null,
currentDeploymentProjId: null,
currentDeploymentMode: null,
currentDeploymentEnvId: null,
currentEditDeploymentId: null,
getTypeInfo(type) {
return (
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
value: type,
label: type,
icon: "❓",
}
);
},
getEnvironmentName(envId) {
const e = projectData.appData.environments.find(
(x) => x.id === envId,
);
return e ? e.name : envId;
},
getEnvironmentColors(envId) {
const e = projectData.appData.environments.find(
(x) => x.id === envId,
);
return {
backgroundColor: e?.backgroundColor || "#38a169",
textColor: e?.textColor || "#ffffff",
};
},
findProjectById(pid) {
for (const prod of projectData.appData.products) {
const pr = prod.projects.find((p) => p.id === pid);
if (pr) return pr;
}
return null;
},
getActiveDeployments(proj) {
return (proj.deployments || []).filter((d) => d.status === "active");
},
getEnvironmentsWithoutDeployment(proj) {
const allEnvIds = projectData.appData.environments.map((e) => e.id);
const deployedEnvIds = this.getActiveDeployments(proj).map(
(d) => d.environmentId,
);
return allEnvIds.filter((id) => !deployedEnvIds.includes(id));
},
showDetail(productId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
this.currentDetailProductId = productId;
document.getElementById("projectDetailTitle").innerHTML =
`<span>📦</span><span>${prod.title}</span>`;
const projects = prod.projects || [];
const totalDeploys = projects.reduce(
(s, pr) => s + this.getActiveDeployments(pr).length,
0,
);
let html = `<div class="detail-section"><div class="detail-section-header"><span>ℹ️ اطلاعات محصول</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openProductEditModal()">✏️ ویرایش</button></div></div><div class="detail-section-body"><div class="detail-info-grid"><div class="detail-info-item"><span class="detail-info-label">عنوان</span><span class="detail-info-value">${prod.title}</span></div><div class="detail-info-item"><span class="detail-info-label">توضیحات</span><span class="detail-info-value">${prod.description || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تاریخ ایجاد</span><span class="detail-info-value">${prod.metadata?.createdAt ? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR") : "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">مالک</span><span class="detail-info-value">${prod.metadata?.owner || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تعداد پروژه</span><span class="detail-info-value">${projects.length}</span></div><div class="detail-info-item"><span class="detail-info-label">استقرارهای فعال</span><span class="detail-info-value">${totalDeploys}</span></div></div></div></div><div class="detail-section"><div class="detail-section-header"><span>🌿 پروژه‌ها (${projects.length})</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openAddProjectModal()">➕ افزودن</button></div></div><div class="detail-section-body">`;
if (projects.length === 0) {
html += '<div class="empty-subprojects">🌿 هیچ پروژه‌ای نیست</div>';
} else {
html += '<div class="subproject-list">';
projects.forEach((pr) => {
const ti = this.getTypeInfo(pr.type);
const deps = pr.dependencies || [];
const deploys = this.getActiveDeployments(pr);
const emptyEnvs = this.getEnvironmentsWithoutDeployment(pr);
const canAdd = emptyEnvs.length > 0;
html += `<div class="subproject-card"><div class="subproject-header"><div class="subproject-title"><span>${ti.icon}</span><span>${pr.title}</span><span class="type-badge type-${pr.type}">${ti.label}</span></div><div class="subproject-actions"><button class="btn btn-ghost btn-sm" onclick="ProductManager.openDependencyManager('${prod.id}','${pr.id}')">🔗 وابستگی‌ها</button><button class="btn btn-ghost btn-sm" onclick="ProductManager.openProjectEditModal('${prod.id}','${pr.id}')">✏️</button><button class="btn btn-danger btn-sm" onclick="ProductManager.removeProject('${prod.id}','${pr.id}')">🗑️</button></div></div><div class="subproject-meta">${pr.description || ""}</div><div class="detail-info-grid" style="margin-bottom:8px">${pr.metadata?.framework ? `<div class="detail-info-item"><span class="detail-info-label">فریم‌ورک</span><span class="detail-info-value">${pr.metadata.framework}</span></div>` : ""}${pr.metadata?.language ? `<div class="detail-info-item"><span class="detail-info-label">زبان</span><span class="detail-info-value">${pr.metadata.language}</span></div>` : ""}${
deps.length > 0
? `<div class="detail-info-item"><span class="detail-info-label">وابستگی‌ها (${deps.length})</span><span class="detail-info-value">${deps
.map((depId) => {
const dp = this.findProjectById(depId);
return dp ? dp.title : depId;
})
.join("، ")}</span></div>`
: ""
}</div>`;
html += `<div class="deployment-section"><div class="deployment-header"><span class="deployment-header-title">🚀 استقرارهای فعال (${deploys.length})</span>${canAdd ? `<button class="btn btn-accent btn-xs" onclick="ProductManager.openAddDeploymentModal('${prod.id}','${pr.id}')">➕ افزودن</button>` : '<span style="font-size:10px;color:var(--text-muted)">✓ کامل</span>'}</div>`;
if (deploys.length === 0) {
html +=
'<div class="empty-deployments">هنوز استقرار ندارد</div>';
} else {
html += '<div class="deployment-list">';
deploys.forEach((d) => {
const en = this.getEnvironmentName(d.environmentId);
const cl = this.getEnvironmentColors(d.environmentId);
const fd = d.deploymentDate
? new Date(d.deploymentDate).toLocaleString("fa-IR")
: "—";
html += `<div class="deployment-item"><div class="deployment-item-info"><span class="deployment-env-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${en}</span><span class="deployment-version">v${d.version}</span><span class="deployment-date">📅 ${fd}</span></div><div class="deployment-actions"><button class="btn btn-ghost btn-xs" onclick="ProductManager.openEditDeploymentModal('${prod.id}','${pr.id}','${d.id}')">✏️</button><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')">🗑️</button></div>`;
if (d.releaseNotes)
html += `<div class="deployment-notes">📝 ${d.releaseNotes}</div>`;
if (
d.requiredDeployments &&
d.requiredDeployments.length > 0
) {
html += `<div class="deployment-req">🔗 نیازمندی‌ها: ${d.requiredDeployments
.map((rd) => {
const rp = this.findProjectById(rd.projectId);
let depVer = "";
if (rp) {
const rdDep = (rp.deployments || []).find(
(x) => x.id === rd.deploymentId,
);
if (rdDep) depVer = ` (v${rdDep.version})`;
}
return `${rp ? rp.title : rd.projectId}${depVer}`;
})
.join("، ")}</div>`;
}
html += `</div>`;
});
html += "</div>";
}
html += `</div></div>`;
});
html += "</div>";
}
html += `</div></div>`;
document.getElementById("projectDetailBody").innerHTML = html;
ModalManager.open("projectDetailModal");
},
openAddProductModal() {
document.getElementById("newProductTitle").value = "";
document.getElementById("newProductDescription").value = "";
document.getElementById("newProductOwner").value = "";
document.getElementById("newProductCreatedAt").value =
new Date().toLocaleDateString("fa-IR");
ModalManager.open("addProductModal");
},
createNewProduct() {
const title = document.getElementById("newProductTitle").value.trim();
if (!title || title.length < 3) {
showToast("عنوان الزامی است (حداقل ۳ کاراکتر)", "error");
return;
}
if (
projectData.appData.products.some(
(p) => p.title.toLowerCase() === title.toLowerCase(),
)
) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.products.push({
id: `prod-${Date.now()}`,
title,
description:
document.getElementById("newProductDescription").value.trim() ||
"",
metadata: {
createdAt: new Date().toISOString(),
owner:
document.getElementById("newProductOwner").value.trim() || "",
customFields: {},
},
projects: [],
});
ModalManager.close("addProductModal");
refreshUI();
showToast(`"${title}" ایجاد شد`, "success");
},
openDependencyManager(productId, projId) {
this.currentDependencyProjectId = productId;
this.currentDependencyProjId = projId;
this.renderCurrentDependencies();
this.renderAllProjectsList();
this.setupDependencySearch();
ModalManager.open("dependencyManagerModal");
},
renderCurrentDependencies() {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!pr) return;
const c = document.getElementById("currentDependenciesList");
const deps = pr.dependencies || [];
if (deps.length === 0) {
c.innerHTML =
'<div class="empty-dependencies">هیچ وابستگی‌ای نیست</div>';
return;
}
c.innerHTML = deps
.map((depId, index) => {
const depPr = this.findProjectById(depId);
const projectName = depPr ? depPr.title : depId;
const typeInfo = depPr ? this.getTypeInfo(depPr.type) : null;
return `<div class="dependency-item"><div class="dependency-info"><div><div class="dependency-project-name">${typeInfo ? typeInfo.icon + " " : ""}${projectName}</div></div></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDependency(${index})">🗑️</button></div>`;
})
.join("");
},
removeDependency(index) {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!pr) return;
if (!confirm("حذف این وابستگی؟")) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
pr.dependencies.splice(index, 1);
ModalManager.close("dependencyManagerModal");
refreshUI();
if (this.currentDetailProductId)
this.showDetail(this.currentDetailProductId);
showToast("وابستگی حذف شد", "success");
},
renderAllProjectsList() {
const rc = document.getElementById("dependencySearchResults");
const cp = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!cp) return;
const cpr = cp.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!cpr) return;
const allProjects = [];
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
if (pr.id !== cpr.id)
allProjects.push({ ...pr, productName: prod.title });
});
});
if (allProjects.length === 0) {
rc.innerHTML =
'<div class="empty-dependencies">پروژه دیگری نیست</div>';
return;
}
rc.innerHTML = allProjects
.map((pr) => {
const isDep = (cpr.dependencies || []).includes(pr.id);
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted);font-weight:normal">(${pr.productName})</small>${isDep ? '<span style="margin-right:auto;font-size:10px;color:var(--success)">✓ وابسته</span>' : ""}</button></div>`;
})
.join("");
},
setupDependencySearch() {
const si = document.getElementById("dependencySearch");
si.value = "";
si.oninput = (e) => {
const q = e.target.value.trim().toLowerCase();
if (q.length === 0) {
this.renderAllProjectsList();
return;
}
const cp = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!cp) return;
const cpr = cp.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!cpr) return;
const allProjects = [];
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
if (pr.id !== cpr.id)
allProjects.push({ ...pr, productName: prod.title });
});
});
const fl = allProjects.filter(
(pr) =>
pr.title.toLowerCase().includes(q) ||
pr.productName.toLowerCase().includes(q) ||
(pr.description || "").toLowerCase().includes(q),
);
const rc = document.getElementById("dependencySearchResults");
if (fl.length === 0) {
rc.innerHTML =
'<div class="empty-dependencies">نتیجه‌ای نیست</div>';
return;
}
rc.innerHTML = fl
.map((pr) => {
const isDep = (cpr.dependencies || []).includes(pr.id);
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted)">(${pr.productName})</small></button></div>`;
})
.join("");
};
},
addDependency(projectId) {
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDependencyProjectId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDependencyProjId,
);
if (!pr) return;
if ((pr.dependencies || []).includes(projectId)) {
showToast("تکراری است", "warning");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (!pr.dependencies) pr.dependencies = [];
pr.dependencies.push(projectId);
ModalManager.close("dependencyManagerModal");
refreshUI();
if (this.currentDetailProductId)
this.showDetail(this.currentDetailProductId);
showToast("وابستگی اضافه شد", "success");
},
openProductEditModal() {
const pid = this.currentDetailProductId;
if (!pid) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
document.getElementById("editProductId").value = prod.id;
document.getElementById("editProductTitle").value = prod.title;
document.getElementById("editProductDescription").value =
prod.description || "";
document.getElementById("editProductOwner").value =
prod.metadata?.owner || "";
document.getElementById("editProductCreatedAt").value = prod.metadata
?.createdAt
? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR")
: "—";
ModalManager.open("productEditModal");
},
saveProductEdit() {
const pid = this.currentDetailProductId;
if (!pid) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const nt = document.getElementById("editProductTitle").value.trim();
if (!nt) {
showToast("عنوان الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
prod.title = nt;
prod.description = document
.getElementById("editProductDescription")
.value.trim();
if (!prod.metadata) prod.metadata = {};
prod.metadata.owner = document
.getElementById("editProductOwner")
.value.trim();
prod.metadata.updatedAt = new Date().toISOString();
ModalManager.close("productEditModal");
refreshUI();
this.showDetail(pid);
showToast(`"${prod.title}" ویرایش شد`, "success");
},
openAddProjectModal() {
if (!this.currentDetailProductId) {
showToast("ابتدا محصول انتخاب کنید", "warning");
return;
}
document.getElementById("newProjectTitle").value = "";
document.getElementById("newProjectType").value = "";
document.getElementById("newProjectFramework").value = "";
document.getElementById("newProjectLanguage").value = "";
document.getElementById("newProjectRepo").value = "";
document.getElementById("newProjectDescription").value = "";
populateProjectTypeDropdowns();
ModalManager.open("addProjectModal");
},
saveNewProject() {
const pid = this.currentDetailProductId;
if (!pid) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const title = document.getElementById("newProjectTitle").value.trim();
const type = document.getElementById("newProjectType").value;
if (!title || !type) {
showToast("عنوان و نوع الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
const np = {
id: `proj-${Date.now()}`,
title,
description:
document.getElementById("newProjectDescription").value.trim() ||
"",
type,
repositoryUrl: document
.getElementById("newProjectRepo")
.value.trim(),
metadata: { createdAt: new Date().toISOString(), customFields: {} },
dependencies: [],
deployments: [],
};
const fw = document
.getElementById("newProjectFramework")
.value.trim();
if (fw) np.metadata.framework = fw;
const lg = document.getElementById("newProjectLanguage").value.trim();
if (lg) np.metadata.language = lg;
prod.projects.push(np);
ModalManager.close("addProjectModal");
refreshUI();
this.showDetail(pid);
showToast(`"${title}" ایجاد شد`, "success");
},
openProjectEditModal(productId, projId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
this.currentEditProjectId = projId;
populateProjectTypeDropdowns();
document.getElementById("editProjId").value = pr.id;
document.getElementById("editProjTitle").value = pr.title;
document.getElementById("editProjType").value = pr.type;
document.getElementById("editProjFramework").value =
pr.metadata?.framework || "";
document.getElementById("editProjLanguage").value =
pr.metadata?.language || "";
document.getElementById("editProjRepo").value =
pr.repositoryUrl || "";
document.getElementById("editProjDescription").value =
pr.description || "";
ModalManager.open("editProjectModal");
},
saveProjectEdit() {
const pid = this.currentDetailProductId;
const projId = this.currentEditProjectId;
if (!pid || !projId) return;
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const nt = document.getElementById("editProjTitle").value.trim();
const nty = document.getElementById("editProjType").value;
if (!nt || !nty) {
showToast("عنوان و نوع الزامی است", "error");
return;
}
HistoryManager.pushState();
AutosaveManager.scheduleSave();
pr.title = nt;
pr.type = nty;
pr.description =
document.getElementById("editProjDescription").value.trim() || "";
pr.repositoryUrl = document
.getElementById("editProjRepo")
.value.trim();
if (!pr.metadata) pr.metadata = {};
const fw = document.getElementById("editProjFramework").value.trim();
if (fw) pr.metadata.framework = fw;
else delete pr.metadata.framework;
const lg = document.getElementById("editProjLanguage").value.trim();
if (lg) pr.metadata.language = lg;
else delete pr.metadata.language;
pr.metadata.updatedAt = new Date().toISOString();
ModalManager.close("editProjectModal");
refreshUI();
this.showDetail(pid);
showToast(`"${pr.title}" ویرایش شد`, "success");
},
openAddDeploymentModal(productId, projId) {
if (!productId || !projId) return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const emptyEnvs = this.getEnvironmentsWithoutDeployment(pr);
if (emptyEnvs.length === 0) {
showToast("همه محیط‌ها استقرار دارند", "warning");
return;
}
this.currentDeploymentProjId = projId;
this.currentDeploymentMode = "add";
this.currentDeploymentEnvId = null;
this.currentEditDeploymentId = null;
document.getElementById("deploymentModalTitle").innerHTML =
"<span>🚀</span><span>افزودن نسخه استقرار</span>";
const envGroup = document.getElementById("deploymentEnvGroup");
const envs = projectData.appData.environments.filter((e) =>
emptyEnvs.includes(e.id),
);
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="required">*</span></label><select class="form-select" id="depEnvironment" onchange="ProductManager.onEnvChange()">${envs
.map((e) => {
const cl = this.getEnvironmentColors(e.id);
return `<option value="${e.id}" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${e.name} - ${e.description}</option>`;
})
.join("")}</select>`;
document.getElementById("depVersion").value = "";
document.getElementById("depDate").value = "";
document.getElementById("depReleaseNotes").value = "";
this.renderRequiredDeploymentsSection();
ModalManager.open("deploymentModal");
},
openEditDeploymentModal(productId, projId, depId) {
if (!productId || !projId || !depId) return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const dep = (pr.deployments || []).find((d) => d.id === depId);
if (!dep) return;
this.currentDeploymentProjId = projId;
this.currentDeploymentMode = "edit";
this.currentDeploymentEnvId = dep.environmentId;
this.currentEditDeploymentId = depId;
document.getElementById("deploymentModalTitle").innerHTML =
"<span>✏️</span><span>ویرایش نسخه استقرار</span>";
const envGroup = document.getElementById("deploymentEnvGroup");
const cl = this.getEnvironmentColors(dep.environmentId);
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="readonly-badge">فقط خواندنی</span></label><div class="env-read-only-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${this.getEnvironmentName(dep.environmentId)}</div>`;
document.getElementById("depVersion").value = dep.version;
let dv = "";
if (dep.deploymentDate) {
const d = new Date(dep.deploymentDate);
const pad = (n) => n.toString().padStart(2, "0");
dv = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
document.getElementById("depDate").value = dv;
document.getElementById("depReleaseNotes").value =
dep.releaseNotes || "";
this.renderRequiredDeploymentsSection(dep);
ModalManager.open("deploymentModal");
},
onEnvChange() {
this.renderRequiredDeploymentsSection();
},
renderRequiredDeploymentsSection(existingDep) {
const section = document.getElementById("requiredDeploymentsSection");
const envSelect = document.getElementById("depEnvironment");
const envId =
this.currentDeploymentMode === "edit"
? this.currentDeploymentEnvId
: envSelect
? envSelect.value
: null;
if (!envId) {
section.innerHTML = "";
return;
}
const prod = projectData.appData.products.find(
(p) => p.id === this.currentDetailProductId,
);
if (!prod) return;
const pr = prod.projects.find(
(p) => p.id === this.currentDeploymentProjId,
);
if (!pr) return;
const deps = pr.dependencies || [];
if (deps.length === 0) {
section.innerHTML = "";
return;
}
let h =
'<div class="form-group"><label class="form-label">🔗 استقرارهای مورد نیاز (به ازای هر وابستگی) <span class="required">*</span></label><div class="form-hint">برای هر وابستگی، یک استقرار از آن پروژه در محیط "' +
this.getEnvironmentName(envId) +
'" انتخاب کنید</div>';
deps.forEach((depId) => {
const depPr = this.findProjectById(depId);
if (!depPr) return;
const depDeployments = (depPr.deployments || []).filter(
(d) => d.status === "active" && d.environmentId === envId,
);
const existingReq = existingDep
? (existingDep.requiredDeployments || []).find(
(r) => r.projectId === depId,
)
: null;
h += `<div class="req-deployment-box"><div class="req-deployment-title">📦 ${depPr.title}</div>`;
if (depDeployments.length === 0) {
h += `<div style="color:var(--danger);font-size:11px">⚠️ هیچ استقرار فعالی در ${this.getEnvironmentName(envId)} ندارد.</div>`;
} else {
h += `<select class="req-deployment-select" data-dep-project="${depId}">${depDeployments.map((d) => `<option value="${d.id}" ${existingReq && existingReq.deploymentId === d.id ? "selected" : ""}>v${d.version} - ${d.deploymentDate ? new Date(d.deploymentDate).toLocaleDateString("fa-IR") : ""}</option>`).join("")}</select>`;
}
h += "</div>";
});
h += "</div>";
section.innerHTML = h;
},
saveDeployment() {
const pid = this.currentDetailProductId;
const projId = this.currentDeploymentProjId;
if (!pid || !projId) {
showToast("خطا: محصول یا پروژه مشخص نشده", "error");
return;
}
const prod = projectData.appData.products.find((p) => p.id === pid);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const version = document.getElementById("depVersion").value.trim();
const date = document.getElementById("depDate").value;
const releaseNotes = document
.getElementById("depReleaseNotes")
.value.trim();
if (!version) {
showToast("نسخه الزامی است", "error");
return;
}
if (!date) {
showToast("تاریخ استقرار الزامی است", "error");
return;
}
if (!releaseNotes) {
showToast("ریلیز نوت الزامی است", "error");
return;
}
const deploymentDate = new Date(date).toISOString();
HistoryManager.pushState();
AutosaveManager.scheduleSave();
if (!pr.deployments) pr.deployments = [];
const requiredDeployments = [];
document.querySelectorAll(".req-deployment-select").forEach((sel) => {
const depProjectId = sel.dataset.depProject;
const depDeploymentId = sel.value;
if (depProjectId && depDeploymentId)
requiredDeployments.push({
projectId: depProjectId,
deploymentId: depDeploymentId,
});
});
if (this.currentDeploymentMode === "add") {
const envId = document.getElementById("depEnvironment").value;
if (!envId) {
showToast("محیط را انتخاب کنید", "error");
return;
}
pr.deployments.push({
id: `dep-${Date.now()}`,
environmentId: envId,
version,
deploymentDate,
releaseNotes,
requiredDeployments,
status: "active",
});
showToast(
`v${version} در ${this.getEnvironmentName(envId)} استقرار یافت`,
"success",
);
} else {
const depId = this.currentEditDeploymentId;
const dep = pr.deployments.find((d) => d.id === depId);
if (dep) {
dep.version = version;
dep.deploymentDate = deploymentDate;
dep.releaseNotes = releaseNotes;
dep.requiredDeployments = requiredDeployments;
showToast(`استقرار ویرایش شد`, "success");
}
}
ModalManager.close("deploymentModal");
refreshUI();
this.showDetail(pid);
},
removeDeployment(productId, projId, depId) {
if (!confirm("حذف این استقرار؟")) return;
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
const dep = (pr.deployments || []).find((d) => d.id === depId);
if (!dep) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
dep.status = "removed";
refreshUI();
this.showDetail(productId);
showToast("استقرار حذف شد", "warning");
},
removeProduct(productId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
if (!confirm(`حذف "${prod.title}"؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
projectData.appData.products = projectData.appData.products.filter(
(p) => p.id !== productId,
);
refreshUI();
ModalManager.close("projectDetailModal");
showToast(`"${prod.title}" حذف شد`, "warning");
},
removeProject(productId, projId) {
const prod = projectData.appData.products.find(
(p) => p.id === productId,
);
if (!prod) return;
const pr = prod.projects.find((p) => p.id === projId);
if (!pr) return;
if (!confirm(`حذف "${pr.title}"؟`)) return;
HistoryManager.pushState();
AutosaveManager.scheduleSave();
prod.projects = prod.projects.filter((p) => p.id !== projId);
refreshUI();
if (this.currentDetailProductId === productId)
this.showDetail(productId);
showToast(`"${pr.title}" حذف شد`, "warning");
},
};
/* ==================== DASHBOARD MANAGER ==================== */
const DashboardManager = {
open() {
ModalManager.open("executiveDashboardModal");
setTimeout(() => this.render(), 150);
},
getReportData() {
const ps = projectData.appData.products || [];
let tp = 0,
td = 0,
tdp = 0;
const tc = {},
edc = {},
ppc = [],
fc = {};
let pwd = 0,
pwod = 0;
ps.forEach((prod) => {
const projects = prod.projects || [];
ppc.push({ title: prod.title, count: projects.length });
projects.forEach((pr) => {
tp++;
td += (pr.dependencies || []).length;
const active = (pr.deployments || []).filter(
(d) => d.status === "active",
);
tdp += active.length;
if (active.length > 0) pwd++;
else pwod++;
tc[pr.type] = (tc[pr.type] || 0) + 1;
const fw = pr.metadata?.framework || "نامشخص";
fc[fw] = (fc[fw] || 0) + 1;
active.forEach((d) => {
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
edc[envName] = (edc[envName] || 0) + 1;
});
});
});
return {
products: ps.length,
projects: tp,
deps: td,
deploys: tdp,
environments: projectData.appData.environments.length,
tc,
edc,
ppc,
fc,
pwd,
pwod,
};
},
render() {
const c = document.getElementById("dashboardContainer");
if (!c) return;
const d = this.getReportData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
h += '</div><div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژه‌ها</div><div class="dashboard-chart-body" id="dashTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="dashEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژه‌ها</div><div class="dashboard-chart-body" id="dashStatusChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژه‌ها به تفکیک محصول</div><div class="dashboard-chart-body" id="dashProductChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🛠️ توزیع فریم‌ورک‌ها</div><div class="dashboard-chart-body" id="dashFrameworkChart"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderDonut(
"dashTypeChart",
Object.entries(d.tc).map(([k, v]) => ({
label: this.getTypeLabel(k),
value: v,
color: this.getTypeColor(k),
})),
);
this.renderBar(
"dashEnvChart",
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
"#38a169",
);
this.renderDonut("dashStatusChart", [
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
]);
this.renderBar(
"dashProductChart",
d.ppc.map((p) => ({
label:
p.title.length > 18
? p.title.substring(0, 18) + "..."
: p.title,
value: p.count,
})),
"#3182ce",
);
this.renderBarH(
"dashFrameworkChart",
Object.entries(d.fc)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([k, v]) => ({
label: k.length > 14 ? k.substring(0, 14) + "..." : k,
value: v,
})),
);
const coverage =
d.pwd + d.pwod > 0
? Math.round((d.pwd / (d.pwd + d.pwod)) * 100)
: 0;
document.getElementById("dashboardFooterStats").innerHTML =
`<span>📦 محصولات: <span class="stat-highlight">${d.products}</span></span><span>🌿 پروژه‌ها: <span class="stat-highlight">${d.projects}</span></span><span>🚀 استقرارها: <span class="stat-highlight">${d.deploys}</span></span><span>📈 پوشش استقرار: <span class="stat-highlight">${coverage}%</span></span>`;
document.getElementById("dashboardRefresh").onclick = () =>
this.render();
},
renderDonut(cid, data) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((dd) => dd.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const total = data.reduce((s, dd) => s + dd.value, 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (dd) => dd.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, dd) {
d3.select(this).transition().duration(150).attr("d", arcH(dd));
showD3Tooltip(
e,
`${dd.data.label}: ${dd.data.value} (${Math.round((dd.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr("fill", "var(--text)")
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(total);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("پروژه");
},
renderBar(cid, data, color) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 45, left: 35 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(data.map((dd) => dd.label))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (dd) => dd.value) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-25)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll(".grid-line")
.data(y.ticks(4))
.join("line")
.attr("x1", 0)
.attr("x2", iw)
.attr("y1", (dd) => y(dd))
.attr("y2", (dd) => y(dd))
.attr("stroke", "var(--border-light)")
.attr("stroke-dasharray", "3,3");
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (dd) => x(dd.label))
.attr("y", (dd) => y(dd.value))
.attr("width", x.bandwidth())
.attr("height", (dd) => ih - y(dd.value))
.attr("fill", color)
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, dd) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", color);
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(data)
.join("text")
.attr("x", (dd) => x(dd.label) + x.bandwidth() / 2)
.attr("y", (dd) => y(dd.value) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((dd) => dd.value);
},
renderBarH(cid, data) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!data || data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>🛠️</span><div>داده‌ای نیست</div></div>';
return;
}
const bh = 28,
gap = 6;
const w = 320,
hgt = data.length * (bh + gap) + 30;
const m = { top: 10, right: 35, bottom: 10, left: 110 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleLinear()
.domain([0, d3.max(data, (dd) => dd.value) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(data.map((dd) => dd.label))
.range([0, ih])
.padding(0.2);
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", 0)
.attr("y", (dd) => y(dd.label))
.attr("width", (dd) => x(dd.value))
.attr("height", y.bandwidth())
.attr("fill", "#2c5282")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, dd) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#2c5282");
hideD3Tooltip();
});
g.selectAll(".label")
.data(data)
.join("text")
.attr("x", -8)
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((dd) => dd.label);
g.selectAll(".value")
.data(data)
.join("text")
.attr("x", (dd) => x(dd.value) + 5)
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((dd) => dd.value);
},
getTypeLabel(t) {
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
return i ? i.label.split(" ")[0] : t;
},
getTypeColor(t) {
const c = {
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
};
return c[t] || "#718096";
},
};
/* ==================== GALAXY CHART ==================== */
const GalaxyChartManager = {
svg: null,
zoomBehavior: null,
zoomGroup: null,
nodeElements: null,
linkElements: null,
labelElements: null,
allNodes: [],
collapsedProducts: new Set(),
NODE_COLORS: {
product: "#c9a961",
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
},
open() {
ModalManager.open("galaxyChartModal");
setTimeout(() => this.render(), 150);
},
buildGraphData() {
const nodes = [],
links = [];
const projectToProduct = new Map();
projectData.appData.products.forEach((prod) => {
(prod.projects || []).forEach((pr) => {
projectToProduct.set(pr.id, prod.id);
});
});
projectData.appData.products.forEach((prod) => {
const isCollapsed = this.collapsedProducts.has(prod.id);
nodes.push({
id: prod.id,
label: prod.title,
type: "product",
radius: 30,
data: prod,
collapsed: isCollapsed,
collapsedCount: isCollapsed ? (prod.projects || []).length : 0,
});
if (!isCollapsed) {
(prod.projects || []).forEach((pr) => {
nodes.push({
id: pr.id,
label: pr.title,
type: "project",
projectType: pr.type,
radius: 16,
data: pr,
productTitle: prod.title,
productId: prod.id,
});
links.push({
source: prod.id,
target: pr.id,
type: "ownership",
});
});
}
});
projectData.appData.products.forEach((prod) => {
(prod.projects || []).forEach((pr) => {
(pr.dependencies || []).forEach((depId) => {
let sourceId = pr.id,
targetId = depId;
const sourceProductId = projectToProduct.get(pr.id);
if (
sourceProductId &&
this.collapsedProducts.has(sourceProductId)
)
sourceId = sourceProductId;
const targetProductId = projectToProduct.get(depId);
if (
targetProductId &&
this.collapsedProducts.has(targetProductId)
)
targetId = targetProductId;
if (sourceId === targetId) return;
const exists = links.some((l) => {
const s =
typeof l.source === "object" ? l.source.id : l.source;
const t =
typeof l.target === "object" ? l.target.id : l.target;
return (
s === sourceId && t === targetId && l.type === "dependency"
);
});
if (!exists)
links.push({
source: sourceId,
target: targetId,
type: "dependency",
});
});
});
});
return { nodes, links };
},
toggleCollapseProduct(productId) {
if (this.collapsedProducts.has(productId))
this.collapsedProducts.delete(productId);
else this.collapsedProducts.add(productId);
this.render();
},
collapseAllProducts() {
projectData.appData.products.forEach((prod) => {
this.collapsedProducts.add(prod.id);
});
this.render();
showToast(
`⊟ ${projectData.appData.products.length} محصول کولاپس شدند`,
"info",
2000,
);
},
expandAllProducts() {
const count = this.collapsedProducts.size;
this.collapsedProducts.clear();
this.render();
showToast(`⊞ ${count} محصول از حالت کولاپس خارج شدند`, "info", 2000);
},
render() {
const c = document.getElementById("galaxyChartContainer");
c.innerHTML = "";
const w = c.clientWidth || 900,
h = c.clientHeight || 600;
const { nodes, links } = this.buildGraphData();
this.allNodes = nodes;
if (nodes.length === 0) {
c.innerHTML =
'<div class="galaxy-empty"><div style="font-size:60px">🌌</div><div>هیچ داده‌ای نیست</div></div>';
return;
}
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", h)
.attr("id", "galaxySvg");
this.svg = svg;
svg
.append("rect")
.attr("width", w)
.attr("height", h)
.attr("fill", "#0a0e27");
for (let i = 0; i < 100; i++) {
svg
.append("circle")
.attr("cx", Math.random() * w)
.attr("cy", Math.random() * h)
.attr("r", Math.random() + 0.3)
.attr("fill", "#fff")
.attr("opacity", Math.random() * 0.5 + 0.1);
}
const defs = svg.append("defs");
const gf = defs.append("filter").attr("id", "galaxyGlow");
gf.append("feGaussianBlur")
.attr("stdDeviation", "3")
.attr("result", "blur");
const fm = gf.append("feMerge");
fm.append("feMergeNode").attr("in", "blur");
fm.append("feMergeNode").attr("in", "SourceGraphic");
this.zoomGroup = svg.append("g");
this.zoomBehavior = d3
.zoom()
.scaleExtent([0.2, 5])
.on("zoom", (e) => {
this.zoomGroup.attr("transform", e.transform);
document.getElementById("galaxyZoomIndicator").textContent =
`🔍 ${Math.round(e.transform.k * 100)}%`;
});
svg.call(this.zoomBehavior);
const sim = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(links)
.id((d) => d.id)
.distance((d) => (d.type === "ownership" ? 100 : 160)),
)
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(w / 2, h / 2));
this.linkElements = this.zoomGroup
.append("g")
.selectAll("line")
.data(links)
.join("line")
.attr("stroke", (d) =>
d.type === "dependency" ? "#e0c789" : "#4a5568",
)
.attr("stroke-width", (d) => (d.type === "dependency" ? 2 : 1))
.attr("stroke-opacity", 0.6)
.attr("stroke-dasharray", (d) =>
d.type === "dependency" ? "5,3" : "none",
);
const node = this.zoomGroup
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.style("cursor", "pointer")
.call(
d3
.drag()
.on("start", (e, d) => {
if (!e.active) sim.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (e, d) => {
d.fx = e.x;
d.fy = e.y;
})
.on("end", (e, d) => {
if (!e.active) sim.alphaTarget(0);
d.fx = null;
d.fy = null;
}),
);
this.nodeElements = node;
node
.append("circle")
.attr("r", (d) => d.radius)
.attr("fill", (d) =>
d.type === "product"
? this.NODE_COLORS.product
: this.NODE_COLORS[d.projectType] || "#718096",
)
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.attr("filter", "url(#galaxyGlow)");
node
.append("text")
.attr("dy", (d) => d.radius + 14)
.attr("text-anchor", "middle")
.attr("fill", "#fff")
.attr("font-size", "10px")
.attr("font-family", "Vazirmatn")
.text((d) => d.label);
node
.filter((d) => d.type === "product" && d.collapsed)
.append("text")
.attr("class", "galaxy-collapse-badge")
.attr("dy", 4)
.attr("text-anchor", "middle")
.attr("fill", "#fff")
.attr("font-size", "11px")
.attr("font-weight", "700")
.text((d) => `+${d.collapsedCount}`);
node.on("click", (e, d) => {
this.showNodeInfo(d);
});
node.on("dblclick", (e, d) => {
if (d.type === "product") {
e.stopPropagation();
this.toggleCollapseProduct(d.id);
}
});
sim.on("tick", () => {
this.linkElements
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
});
this.renderLegend();
this.updateStats(nodes, links);
this.setupSearch();
},
getTypeLabel(t) {
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
return i ? i.label.split(" ")[0] : t;
},
renderLegend() {
const l = document.getElementById("galaxyLegend");
let h = '<div class="galaxy-legend-title">🎨 راهنما</div>';
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS.product}"></div><span>📦 محصول</span></div>`;
VALID_PROJECT_TYPES.forEach((t) => {
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS[t.value]}"></div><span>${t.icon} ${t.label.split(" ")[0]}</span></div>`;
});
l.innerHTML = h;
},
updateStats(nodes, links) {
const p = projectData.appData.products.length;
const pr = projectData.appData.products.reduce(
(x, p) => x + p.projects.length,
0,
);
const dp = links.filter((l) => l.type === "dependency").length;
document.getElementById("galaxyStats").innerHTML =
`<span>📦 ${p}</span><span>🌿 ${pr}</span><span>🔗 ${dp}</span>`;
},
showNodeInfo(d) {
const p = document.getElementById("galaxyInfoPanel");
const ic = document.getElementById("galaxyInfoContent");
let ih = "";
if (d.type === "product") {
const projects = d.data.projects || [];
const isCollapsed = this.collapsedProducts.has(d.id);
ih += `<h4>📦 ${d.label}</h4>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">توضیحات</span><span class="galaxy-info-value">${d.data.description || "—"}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">مالک</span><span class="galaxy-info-value">${d.data.metadata?.owner || "—"}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">تعداد پروژه</span><span class="galaxy-info-value">${projects.length}</span></div>`;
if (projects.length > 0) {
ih += `<div style="margin-top:10px;border-top:1px solid rgba(255,255,255,.1);padding-top:8px"><div style="font-weight:700;font-size:11px;margin-bottom:6px">🌿 پروژه‌ها:</div>`;
projects.forEach((pr) => {
const ti = this.getTypeInfo(pr.type);
ih += `<div style="font-size:11px;padding:3px 0;color:rgba(255,255,255,.8)">${ti.icon} ${pr.title}</div>`;
});
ih += `</div>`;
}
} else {
const ti = this.getTypeInfo(d.projectType);
const deploys = ProductManager.getActiveDeployments(d.data);
ih += `<h4>${ti.icon} ${d.label}</h4>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">نوع</span><span class="galaxy-info-value">${ti.label}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">فریم‌ورک</span><span class="galaxy-info-value">${d.data.metadata?.framework || "—"}</span></div>`;
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">استقرارها</span><span class="galaxy-info-value">${deploys.length}</span></div>`;
}
ic.innerHTML = ih;
p.classList.add("visible");
},
getTypeInfo(type) {
return (
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
value: type,
label: type,
icon: "❓",
}
);
},
setupSearch() {
const inp = document.getElementById("galaxySearchInput");
const clr = document.getElementById("galaxySearchClear");
if (!inp || !clr) return;
inp.value = "";
clr.classList.remove("visible");
inp.oninput = () => this.applySearch(inp.value.trim());
inp.onkeydown = (e) => {
if (e.key === "Escape" && inp.value) {
e.stopPropagation();
this.clearSearch();
inp.focus();
}
};
clr.onclick = () => {
this.clearSearch();
inp.focus();
};
},
applySearch(query) {
const clr = document.getElementById("galaxySearchClear");
const results = document.getElementById("galaxySearchResults");
if (clr) clr.classList.toggle("visible", !!query);
if (!query) {
this.clearSearch();
return;
}
const q = query.toLowerCase();
const matches = this.allNodes.filter((n) => {
const hay = [
n.label,
n.productTitle || "",
n.data?.description || "",
n.data?.metadata?.framework || "",
n.data?.metadata?.language || "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
});
const matchIds = new Set(matches.map((m) => m.id));
if (this.nodeElements) {
this.nodeElements
.classed("galaxy-dim", (d) => !matchIds.has(d.id))
.classed("galaxy-match", (d) => matchIds.has(d.id));
}
if (this.linkElements) {
this.linkElements.classed("galaxy-dim", (l) => {
const s = typeof l.source === "object" ? l.source.id : l.source;
const t = typeof l.target === "object" ? l.target.id : l.target;
return !(matchIds.has(s) || matchIds.has(t));
});
}
if (!results) return;
if (matches.length === 0) {
results.innerHTML =
'<div class="galaxy-search-empty">نتیجه‌ای یافت نشد</div>';
} else {
results.innerHTML = matches
.slice(0, 30)
.map((n) => {
const icon =
n.type === "product"
? "📦"
: ProductManager.getTypeInfo(n.projectType)?.icon || "❓";
const sub =
n.type === "product"
? "محصول"
: ProductManager.getTypeInfo(n.projectType)?.label || "";
return `<div class="galaxy-search-result-item" data-node-id="${n.id}"><span class="galaxy-search-result-icon">${icon}</span><div class="galaxy-search-result-text"><div class="galaxy-search-result-title">${n.label}</div><div class="galaxy-search-result-sub">${sub}</div></div></div>`;
})
.join("");
results
.querySelectorAll(".galaxy-search-result-item")
.forEach((item) => {
item.addEventListener("click", () => {
this.focusNode(item.dataset.nodeId);
results.classList.remove("visible");
});
});
}
results.classList.add("visible");
},
clearSearch() {
const inp = document.getElementById("galaxySearchInput");
const clr = document.getElementById("galaxySearchClear");
const results = document.getElementById("galaxySearchResults");
if (inp) inp.value = "";
if (clr) clr.classList.remove("visible");
if (results) {
results.innerHTML = "";
results.classList.remove("visible");
}
if (this.nodeElements)
this.nodeElements
.classed("galaxy-dim", false)
.classed("galaxy-match", false)
.classed("galaxy-focus", false);
if (this.linkElements) this.linkElements.classed("galaxy-dim", false);
},
focusNode(nodeId) {
const n = this.allNodes.find((x) => x.id === nodeId);
if (!n || !this.svg || !this.zoomBehavior) return;
const c = document.getElementById("galaxyChartContainer");
const w = c.clientWidth || 900,
h = c.clientHeight || 600;
const scale = 1.6;
const t = d3.zoomIdentity
.translate(w / 2 - scale * (n.x || 0), h / 2 - scale * (n.y || 0))
.scale(scale);
this.svg
.transition()
.duration(600)
.call(this.zoomBehavior.transform, t);
this.showNodeInfo(n);
if (this.nodeElements) {
this.nodeElements.classed("galaxy-focus", (d) => d.id === nodeId);
setTimeout(() => {
if (this.nodeElements)
this.nodeElements.classed("galaxy-focus", false);
}, 2000);
}
},
};
/* ==================== AUTOSAVE MANAGER ==================== */
const AutosaveManager = {
STORAGE_KEY: "xrelease-appdata-autosave",
saveTimer: null,
lastSaveTime: null,
DEBOUNCE_MS: 1500,
scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), this.DEBOUNCE_MS);
},
save() {
try {
const data = {
version: projectData.version || "1.2.0",
appData: JSON.parse(JSON.stringify(projectData.appData)),
activeBoardId: ReleaseBoardsManager?.activeBoardId || null,
savedAt: new Date().toISOString(),
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
this.lastSaveTime = new Date();
this.showIndicator();
} catch (e) {
console.error("Autosave failed:", e);
}
},
load() {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return null;
try {
return JSON.parse(saved);
} catch (e) {
return null;
}
},
clear() {
localStorage.removeItem(this.STORAGE_KEY);
},
showIndicator() {
const indicator = document.getElementById("rbAutosaveIndicator");
const text = document.getElementById("rbAutosaveText");
if (!indicator || !text) return;
text.textContent = "✓ ذخیره خودکار انجام شد";
indicator.style.opacity = "1";
setTimeout(() => {
indicator.style.opacity = "0";
}, 2000);
},
checkRecovery() {
const saved = this.load();
if (!saved || !saved.appData) return;
const appData = saved.appData;
const stats = {
environments: (appData.environments || []).length,
products: (appData.products || []).length,
projects: 0,
deployments: 0,
boards: (appData.boards || []).length,
boardTags: (appData.boardTags || []).length,
};
(appData.products || []).forEach((prod) => {
stats.projects += (prod.projects || []).length;
(prod.projects || []).forEach((pr) => {
stats.deployments += (pr.deployments || []).filter(
(d) => d.status === "active",
).length;
});
});
const dateEl = document.getElementById("rbAutosaveDate");
const envCountEl = document.getElementById("rbAutosaveEnvCount");
const prodCountEl = document.getElementById("rbAutosaveProdCount");
const projCountEl = document.getElementById("rbAutosaveProjCount");
const depCountEl = document.getElementById("rbAutosaveDepCount");
const boardCountEl = document.getElementById("rbAutosaveBoardCount");
if (dateEl) {
const d = new Date(saved.savedAt);
dateEl.textContent =
d.toLocaleDateString("fa-IR") +
" - " +
d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
});
}
if (envCountEl)
envCountEl.textContent = stats.environments.toLocaleString("fa-IR");
if (prodCountEl)
prodCountEl.textContent = stats.products.toLocaleString("fa-IR");
if (projCountEl)
projCountEl.textContent = stats.projects.toLocaleString("fa-IR");
if (depCountEl)
depCountEl.textContent = stats.deployments.toLocaleString("fa-IR");
if (boardCountEl)
boardCountEl.textContent = stats.boards.toLocaleString("fa-IR");
document.getElementById("rbRecoverAutosaveBtn").onclick = () =>
this.recover();
document.getElementById("rbDiscardAutosaveBtn").onclick = () =>
this.discard();
setTimeout(() => {
ModalManager.open("rbAutosaveModal");
}, 600);
},
recover() {
const saved = this.load();
if (!saved || !saved.appData) {
ModalManager.close("rbAutosaveModal");
return;
}
HistoryManager.pushState();
projectData.appData = saved.appData;
if (saved.activeBoardId && ReleaseBoardsManager)
ReleaseBoardsManager.activeBoardId = saved.activeBoardId;
this.clear();
ModalManager.close("rbAutosaveModal");
updateProjectUI();
renderToolGroups();
ToolbarManager.populateEnvironmentFilter();
refreshUI();
if (ReleaseBoardsManager) {
ReleaseBoardsManager.render(true);
ReleaseBoardsManager.updateUndoRedoButtons();
}
showToast("✅ داده‌های خودکار با موفقیت بازیابی شدند", "success");
},
discard() {
this.clear();
ModalManager.close("rbAutosaveModal");
showToast("🗑️ داده‌های خودکار نادیده گرفته شدند", "info");
},
};
/* ==================== RELEASE BOARDS MANAGER ==================== */
const ReleaseBoardsManager = {
activeBoardId: null,
editing: {
boardId: null,
columnId: null,
cardId: null,
columnForCard: null,
},
tempData: {},
_tabsCache: "",
_dateCache: new Map(),
_eventsBound: false,
_lastUndoRedoState: { undo: -1, redo: -1 },
_renderCache: { boardId: null, columnsHash: "" },
filters: { search: "", tags: [] },
dragType: null,
dragCardId: null,
dragColumnId: null,
DEFAULT_COLUMNS: [
{
title: "📥 بک لاگ",
backgroundColor: "#edf2f7",
textColor: "#1a202c",
},
{
title: "📋 برای انجام",
backgroundColor: "#ebf8ff",
textColor: "#1a202c",
},
{
title: "🔨 در حال انجام",
backgroundColor: "#fef5e7",
textColor: "#1a202c",
},
{
title: "🔍 برای تست",
backgroundColor: "#faf5ff",
textColor: "#1a202c",
},
{
title: "✅ انجام شده",
backgroundColor: "#f0fff4",
textColor: "#1a202c",
},
],
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);
}
/* ✅ NEW: اعمال فیلترها و اتصال درگ پس از رندر */
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 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>` : ""}
<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>`;
},
/* ═══════════════════════════════════════════════════
✅ NEW: فیلتر تگ‌ها
═══════════════════════════════════════════════════ */
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.classList.remove("visible");
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);
else this.filters.tags.push(tid);
this.renderFilterBar();
this.applyFilters();
});
});
if (clearBtn) {
clearBtn.classList.toggle("visible", this.filters.tags.length > 0);
clearBtn.onclick = () => {
this.filters.tags = [];
this.renderFilterBar();
this.applyFilters();
showToast("🏷️ فیلتر تگ پاک شد", "info");
};
}
},
/* ═══════════════════════════════════════════════════
✅ NEW: اعمال فیلتر و جستجو
═══════════════════════════════════════════════════ */
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");
}
}
},
/* ═══════════════════════════════════════════════════
✅ NEW: رویدادهای جستجو
═══════════════════════════════════════════════════ */
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();
};
},
/* ═══════════════════════════════════════════════════
✅ NEW: Drag & Drop
═══════════════════════════════════════════════════ */
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({
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";
this.tempData.cardTags = [];
this.tempData.checklist = [];
this.renderCardTagsEditor();
this.renderChecklistEditor();
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";
this.tempData.cardTags = [...(card.tags || [])];
this.tempData.checklist = (card.checklist || []).map((i) => ({
...i,
}));
this.renderCardTagsEditor();
this.renderChecklistEditor();
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) {
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.updatedAt = 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: [],
dependencies: [],
history: [
{
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();
},
/* ── Toolbar Events ── */
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>`;
},
/* ── Tag Manager ── */
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();
};
},
escapeHtml(str) {
if (!str) return "";
return String(str).replace(
/[&<>"']/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c],
);
},
};
/* ==================== ANALYSIS MANAGERS ==================== */
const DependencyAnalysisManager = {
open() {
ModalManager.open("dependencyAnalysisModal");
setTimeout(() => this.render(), 150);
},
getData() {
let totalDeps = 0,
totalProjects = 0,
brokenDeps = 0;
const allProjects = [];
const inDeps = {};
const typeCounts = {};
const matrix = {};
projectData.appData.products.forEach((prod) => {
prod.projects.forEach((pr) => {
allProjects.push({ ...pr, productName: prod.title });
totalProjects++;
matrix[pr.id] = {};
(pr.dependencies || []).forEach((depId) => {
totalDeps++;
if (!ProductManager.findProjectById(depId)) brokenDeps++;
else {
inDeps[depId] = (inDeps[depId] || 0) + 1;
matrix[pr.id][depId] = true;
}
typeCounts[pr.type] = (typeCounts[pr.type] || 0) + 1;
});
});
});
const bottlenecks = Object.entries(inDeps)
.filter(([k, v]) => v >= 2)
.map(([id, count]) => ({
project: ProductManager.findProjectById(id),
count,
}))
.sort((a, b) => b.count - a.count);
const isolated = allProjects.filter(
(pr) =>
(pr.dependencies || []).length === 0 && !(inDeps[pr.id] > 0),
);
return {
totalDeps,
totalProjects,
brokenDeps,
bottlenecks,
isolated,
typeCounts,
matrix,
allProjects,
inDeps,
};
},
render() {
const c = document.getElementById("dependencyAnalysisContainer");
if (!c) return;
const d = this.getData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.totalDeps}</div><div class="dashboard-kpi-label">کل وابستگی‌ها</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.totalProjects}</div><div class="dashboard-kpi-label">پروژه‌ها</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.bottlenecks.length}</div><div class="dashboard-kpi-label">گلوگاه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.brokenDeps}</div><div class="dashboard-kpi-label">شکسته</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.isolated.length}</div><div class="dashboard-kpi-label">ایزوله</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 گلوگاه‌ها (بیشترین وابستگی ورودی)</div><div class="dashboard-chart-body" id="depHubChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژه‌های وابسته</div><div class="dashboard-chart-body" id="depTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">🟥 ماتریس وابستگی</div><div class="dashboard-chart-body" id="depMatrixChart" style="overflow:auto;max-height:300px"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها</div><div class="dashboard-chart-body" id="depRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderHubChart("depHubChart", d.bottlenecks);
this.renderTypeDonut("depTypeChart", d.typeCounts);
this.renderMatrix("depMatrixChart", d.allProjects, d.matrix);
this.renderRiskList("depRiskList", d);
document.getElementById("depAnalysisFooterStats").innerHTML =
`<span>🔗 کل: <span class="stat-highlight">${d.totalDeps}</span></span><span>⚠️ گلوگاه: <span class="stat-highlight">${d.bottlenecks.length}</span></span><span>🔴 شکسته: <span class="stat-highlight">${d.brokenDeps}</span></span><span>🔵 ایزوله: <span class="stat-highlight">${d.isolated.length}</span></span>`;
document.getElementById("depAnalysisRefresh").onclick = () =>
this.render();
},
renderHubChart(cid, bottlenecks) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!bottlenecks || bottlenecks.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>گلوگاهی نیست</div></div>';
return;
}
const w = 320,
hgt = Math.max(120, bottlenecks.length * 40 + 40);
const m = { top: 10, right: 35, bottom: 10, left: 130 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleLinear()
.domain([0, d3.max(bottlenecks, (d) => d.count) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(bottlenecks.map((d) => (d.project ? d.project.title : "")))
.range([0, ih])
.padding(0.3);
g.selectAll("rect")
.data(bottlenecks)
.join("rect")
.attr("x", 0)
.attr("y", (d) => y(d.project ? d.project.title : ""))
.attr("width", (d) => x(d.count))
.attr("height", y.bandwidth())
.attr("fill", (d) => (d.count >= 3 ? "#e53e3e" : "#dd6b20"))
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(
e,
`${d.project ? d.project.title : ""}: ${d.count} وابستگی ورودی`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, d) {
d3.select(this).attr(
"fill",
d.count >= 3 ? "#e53e3e" : "#dd6b20",
);
hideD3Tooltip();
});
g.selectAll(".label")
.data(bottlenecks)
.join("text")
.attr("x", -8)
.attr(
"y",
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "600")
.text((d) => {
const t = d.project ? d.project.title : "";
return t.length > 18 ? t.substring(0, 18) + "..." : t;
});
g.selectAll(".value")
.data(bottlenecks)
.join("text")
.attr("x", (d) => x(d.count) + 5)
.attr(
"y",
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.count);
},
renderTypeDonut(cid, typeCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(typeCounts);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>🥧</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((d) => d[1])
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const total = entries.reduce((s, d) => s + d[1], 0);
const colors = {
backend: "#4299e1",
frontend: "#48bb78",
service: "#ecc94b",
database: "#ed64a6",
mobile: "#9f7aea",
infrastructure: "#ed8936",
};
g.selectAll("path")
.data(pie(entries))
.join("path")
.attr("d", arc)
.attr("fill", (d) => colors[d.data[0]] || "#718096")
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).transition().duration(150).attr("d", arcH(d));
const ti = VALID_PROJECT_TYPES.find((t) => t.value === d.data[0]);
showD3Tooltip(
e,
`${ti ? ti.label : d.data[0]}: ${d.data[1]} (${Math.round((d.data[1] / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr("fill", "var(--text)")
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(total);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("وابستگی");
},
renderMatrix(cid, allProjects, matrix) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
if (!allProjects || allProjects.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>🟥</span><div>داده‌ای نیست</div></div>';
return;
}
const cs = 28,
lw = 140;
const w = lw + allProjects.length * cs + 20;
const hgt = 40 + allProjects.length * cs + 20;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg.append("g");
allProjects.forEach((pr, i) => {
g.append("text")
.attr("x", lw - 8)
.attr("y", 40 + i * cs + cs / 2)
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-family", "Vazirmatn")
.text(
pr.title.length > 20
? pr.title.substring(0, 20) + "..."
: pr.title,
);
});
allProjects.forEach((pr, i) => {
g.append("text")
.attr("x", lw + i * cs + cs / 2)
.attr("y", 30)
.attr("text-anchor", "middle")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", `rotate(-45, ${lw + i * cs + cs / 2}, 30)`)
.text(
pr.title.length > 12
? pr.title.substring(0, 12) + "..."
: pr.title,
);
});
allProjects.forEach((row, i) => {
allProjects.forEach((col, j) => {
const hasDep = matrix[row.id] && matrix[row.id][col.id];
g.append("rect")
.attr("x", lw + j * cs)
.attr("y", 40 + i * cs)
.attr("width", cs - 2)
.attr("height", cs - 2)
.attr("rx", 3)
.attr("fill", hasDep ? "#e53e3e" : "var(--border-light)")
.attr("opacity", hasDep ? 0.9 : 0.5)
.style("cursor", hasDep ? "pointer" : "default")
.on(
"mouseover",
hasDep
? (e) => showD3Tooltip(e, `${row.title} → ${col.title}`)
: null,
)
.on("mousemove", hasDep ? (e) => showD3Tooltip(e, "") : null)
.on("mouseout", hasDep ? () => hideD3Tooltip() : null);
});
});
},
renderRiskList(cid, d) {
const c = document.getElementById(cid);
if (!c) return;
let h = "";
if (d.brokenDeps > 0)
h += `<div style="padding:10px 12px;background:rgba(229,62,62,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #e53e3e"><div style="font-weight:700;font-size:12px">🔴 وابستگی‌های شکسته</div><div style="font-size:11px;color:var(--text-muted)">${d.brokenDeps} وابستگی به پروژه‌های ناموجود</div></div>`;
d.bottlenecks.forEach((b) => {
h += `<div style="padding:10px 12px;background:rgba(221,107,32,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #dd6b20"><div style="font-weight:700;font-size:12px">⚠️ گلوگاه: ${b.project ? b.project.title : ""}</div><div style="font-size:11px;color:var(--text-muted)">${b.count} پروژه به این پروژه وابسته‌اند - ریسک بالا در صورت تغییر</div></div>`;
});
if (d.isolated.length > 0)
h += `<div style="padding:10px 12px;background:rgba(49,130,206,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #3182ce"><div style="font-weight:700;font-size:12px">🔵 پروژه‌های ایزوله</div><div style="font-size:11px;color:var(--text-muted)">${d.isolated.map((p) => p.title).join("، ")}</div></div>`;
if (!h)
h =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
c.innerHTML = h;
},
};
const DeploymentAnalysisManager = {
open() {
ModalManager.open("deploymentAnalysisModal");
setTimeout(() => this.render(), 150);
},
getData() {
let total = 0,
pwd = 0,
pwod = 0,
withNotes = 0,
withoutNotes = 0;
const edc = {},
ppc = [],
riskList = [];
const now = new Date();
const thirtyDaysAgo = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
);
projectData.appData.products.forEach((prod) => {
let prodDeployCount = 0;
prod.projects.forEach((pr) => {
const active = ProductManager.getActiveDeployments(pr);
if (active.length > 0) pwd++;
else {
pwod++;
riskList.push({
type: "no-deploy",
severity: "high",
title: "بدون استقرار",
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
});
}
active.forEach((d) => {
total++;
prodDeployCount++;
if (d.releaseNotes) withNotes++;
else {
withoutNotes++;
riskList.push({
type: "no-notes",
severity: "medium",
title: "بدون ریلیز نوت",
desc: `${pr.title} v${d.version}`,
rec: "ریلیز نوت را تکمیل کنید",
});
}
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
edc[envName] = (edc[envName] || 0) + 1;
if (
d.deploymentDate &&
new Date(d.deploymentDate) < thirtyDaysAgo
) {
const daysOld = Math.floor(
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
);
riskList.push({
type: "stale",
severity: daysOld > 90 ? "high" : "medium",
title: "استقرار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "بررسی و به‌روزرسانی نسخه",
});
}
});
});
ppc.push({ title: prod.title, count: prodDeployCount });
});
const coverage =
pwd + pwod > 0 ? Math.round((pwd / (pwd + pwod)) * 100) : 0;
return {
total,
pwd,
pwod,
withNotes,
withoutNotes,
edc,
ppc,
riskList,
coverage,
};
},
render() {
const c = document.getElementById("deploymentAnalysisContainer");
if (!c) return;
const d = this.getData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل استقرارها</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">✅</div><div class="dashboard-kpi-value">${d.pwd}</div><div class="dashboard-kpi-label">با استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">❌</div><div class="dashboard-kpi-value">${d.pwod}</div><div class="dashboard-kpi-label">بدون استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value">${d.coverage}%</div><div class="dashboard-kpi-label">پوشش استقرار</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📝</div><div class="dashboard-kpi-value">${d.withNotes}</div><div class="dashboard-kpi-label">با ریلیز نوت</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.withoutNotes}</div><div class="dashboard-kpi-label">بدون ریلیز نوت</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="deployEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژه‌ها</div><div class="dashboard-chart-body" id="deployStatusChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 استقرارها به تفکیک محصول</div><div class="dashboard-chart-body" id="deployProductChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها</div><div class="dashboard-chart-body" id="deployRiskList" style="align-items:flex-start;overflow-y:auto;max-height:280px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderEnvBarChart("deployEnvChart", d.edc);
this.renderStatusDonut(
"deployStatusChart",
d.pwd,
d.pwod,
d.coverage,
);
this.renderProductBarChart("deployProductChart", d.ppc);
this.renderRiskList("deployRiskList", d.riskList);
document.getElementById("deployAnalysisFooterStats").innerHTML =
`<span>🚀 کل: <span class="stat-highlight">${d.total}</span></span><span>📈 پوشش: <span class="stat-highlight">${d.coverage}%</span></span><span>⚠️ ریسک‌ها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
document.getElementById("deployAnalysisRefresh").onclick = () =>
this.render();
},
renderEnvBarChart(cid, edc) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const data = Object.entries(edc).map(([k, v]) => ({
label: k,
value: v,
}));
if (data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 45, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-20)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d) => x(d.label))
.attr("y", (d) => y(d.value))
.attr("width", x.bandwidth())
.attr("height", (d) => ih - y(d.value))
.attr("fill", "#38a169")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d.label}: ${d.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#38a169");
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(data)
.join("text")
.attr("x", (d) => x(d.label) + x.bandwidth() / 2)
.attr("y", (d) => y(d.value) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.value);
},
renderStatusDonut(cid, pwd, pwod, coverage) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const total = pwd + pwod;
if (total === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((d) => d.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const data = [
{ label: "با استقرار", value: pwd, color: "#38a169" },
{ label: "بدون استقرار", value: pwod, color: "#e53e3e" },
].filter((d) => d.value > 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (d) => d.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).transition().duration(150).attr("d", arcH(d));
showD3Tooltip(
e,
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr(
"fill",
coverage >= 70
? "#38a169"
: coverage >= 40
? "#dd6b20"
: "#e53e3e",
)
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(`${coverage}%`);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("پوشش استقرار");
},
renderProductBarChart(cid, ppc) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const data = ppc.map((p) => ({
label:
p.title.length > 15 ? p.title.substring(0, 15) + "..." : p.title,
value: p.count,
}));
if (data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const bh = 28,
gap = 6;
const w = 320,
hgt = data.length * (bh + gap) + 30;
const m = { top: 10, right: 35, bottom: 10, left: 130 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, ih])
.padding(0.2);
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", 0)
.attr("y", (d) => y(d.label))
.attr("width", (d) => x(d.value))
.attr("height", y.bandwidth())
.attr("fill", "#3182ce")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${d.label}: ${d.value}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#3182ce");
hideD3Tooltip();
});
g.selectAll(".label")
.data(data)
.join("text")
.attr("x", -8)
.attr("y", (d) => y(d.label) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((d) => d.label);
g.selectAll(".value")
.data(data)
.join("text")
.attr("x", (d) => x(d.value) + 5)
.attr("y", (d) => y(d.label) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((d) => d.value);
},
renderRiskList(cid, riskList) {
const c = document.getElementById(cid);
if (!c) return;
if (riskList.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
return;
}
const colors = {
critical: "#e53e3e",
high: "#dd6b20",
medium: "#d69e2e",
};
let h = "";
riskList.forEach((r) => {
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
h += `<div style="font-weight:700;font-size:12px">${r.title}</div>`;
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
h += "</div>";
});
c.innerHTML = h;
},
};
const VersionAnalysisManager = {
open() {
ModalManager.open("versionAnalysisModal");
setTimeout(() => this.render(), 150);
},
getData() {
let total = 0;
const uniqueVersions = new Set();
const majorCounts = {},
envCounts = {},
envMatrix = {},
riskList = [];
const now = new Date();
const thirtyDaysAgo = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
);
const ninetyDaysAgo = new Date(
now.getTime() - 90 * 24 * 60 * 60 * 1000,
);
const prereleaseRegex = /-(rc|beta|dev|alpha)/i;
projectData.appData.environments.forEach((e) => {
envCounts[e.name] = 0;
});
projectData.appData.products.forEach((prod) => {
(prod.projects || []).forEach((pr) => {
const active = ProductManager.getActiveDeployments(pr);
envMatrix[pr.title] = {};
active.forEach((d) => {
total++;
uniqueVersions.add(d.version);
const major = parseInt(d.version.split(".")[0]) || 0;
const majorKey = `v${major}.x`;
majorCounts[majorKey] = (majorCounts[majorKey] || 0) + 1;
const env = projectData.appData.environments.find(
(e) => e.id === d.environmentId,
);
const envName = env ? env.name : "نامشخص";
envCounts[envName] = (envCounts[envName] || 0) + 1;
envMatrix[pr.title][d.environmentId] = d.version;
if (
d.deploymentDate &&
new Date(d.deploymentDate) < ninetyDaysAgo
) {
const daysOld = Math.floor(
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
);
riskList.push({
type: "stale-critical",
severity: "high",
icon: "⏰",
title: "نسخه بسیار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "فوراً بررسی و به‌روزرسانی کنید",
});
} else if (
d.deploymentDate &&
new Date(d.deploymentDate) < thirtyDaysAgo
) {
const daysOld = Math.floor(
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
);
riskList.push({
type: "stale-medium",
severity: "medium",
icon: "⏰",
title: "نسخه قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "برنامه‌ریزی به‌روزرسانی",
});
}
if (!d.releaseNotes)
riskList.push({
type: "no-notes",
severity: "medium",
icon: "📝",
title: "بدون ریلیز نوت",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "ریلیز نوت را تکمیل کنید",
});
if (prereleaseRegex.test(d.version)) {
if (d.environmentId === "env-prod-001")
riskList.push({
type: "prerelease-prod",
severity: "high",
icon: "🧪",
title: "نسخه پیش‌نویس در Production",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "نسخه پایدار را در Production مستقر کنید",
});
else
riskList.push({
type: "prerelease-other",
severity: "low",
icon: "🧪",
title: "نسخه پیش‌نویس",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "پس از تست، نسخه پایدار را منتشر کنید",
});
}
});
});
});
let avgMajor = 0,
avgMinor = 0,
avgPatch = 0;
if (uniqueVersions.size > 0) {
let sumMajor = 0,
sumMinor = 0,
sumPatch = 0;
uniqueVersions.forEach((v) => {
const parts = v.split(".");
sumMajor += parseInt(parts[0]) || 0;
sumMinor += parseInt(parts[1]) || 0;
sumPatch += parseInt(parts[2]) || 0;
});
avgMajor = Math.round(sumMajor / uniqueVersions.size);
avgMinor = Math.round(sumMinor / uniqueVersions.size);
avgPatch = Math.round(sumPatch / uniqueVersions.size);
}
const prereleaseCount = [...uniqueVersions].filter((v) =>
prereleaseRegex.test(v),
).length;
return {
total,
uniqueVersions: uniqueVersions.size,
avgVersion: `${avgMajor}.${avgMinor}.${avgPatch}`,
prereleaseCount,
majorCounts,
envCounts,
envMatrix,
riskList,
};
},
render() {
const c = document.getElementById("versionAnalysisContainer");
if (!c) return;
const d = this.getData();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔢</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل نسخه‌ها</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.uniqueVersions}</div><div class="dashboard-kpi-label">نسخه‌های یکتا</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value" style="font-size:20px">${d.avgVersion}</div><div class="dashboard-kpi-label">میانگین نسخه</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🧪</div><div class="dashboard-kpi-value">${d.prereleaseCount}</div><div class="dashboard-kpi-label">پیش‌نویس</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.riskList.length}</div><div class="dashboard-kpi-label">ریسک‌ها</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نسخه‌ها بر اساس Major</div><div class="dashboard-chart-body" id="verMajorChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 نسخه‌ها به تفکیک محیط</div><div class="dashboard-chart-body" id="verEnvChart"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">📋 مقایسه نسخه‌ها بین محیط‌ها</div><div class="dashboard-chart-body" id="verEnvMatrix" style="overflow:auto;max-height:300px;width:100%"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها</div><div class="dashboard-chart-body" id="verRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderMajorBarChart("verMajorChart", d.majorCounts);
this.renderEnvBarChart("verEnvChart", d.envCounts);
this.renderEnvMatrix("verEnvMatrix", d.envMatrix);
this.renderRiskList("verRiskList", d.riskList);
document.getElementById("verAnalysisFooterStats").innerHTML =
`<span>🔢 کل: <span class="stat-highlight">${d.total}</span></span><span>📦 یکتا: <span class="stat-highlight">${d.uniqueVersions}</span></span><span>📈 میانگین: <span class="stat-highlight">${d.avgVersion}</span></span><span>⚠️ ریسک‌ها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
document.getElementById("verAnalysisRefresh").onclick = () =>
this.render();
},
renderMajorBarChart(cid, majorCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(majorCounts).sort((a, b) =>
a[0].localeCompare(b[0]),
);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 40, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(entries.map((x) => x[0]))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(entries, (x) => x[1]) || 1])
.nice()
.range([ih, 0]);
const colors = [
"#4299e1",
"#48bb78",
"#ecc94b",
"#ed64a6",
"#9f7aea",
"#ed8936",
];
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px")
.attr("font-family", "Vazirmatn");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", (x) => x(x[0]))
.attr("y", (x) => y(x[1]))
.attr("width", x.bandwidth())
.attr("height", (x) => ih - y(x[1]))
.attr("fill", (x, i) => colors[i % colors.length])
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${x[0]}: ${x[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, x) {
d3.select(this).attr(
"fill",
colors[entries.indexOf(x) % colors.length],
);
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(entries)
.join("text")
.attr("x", (x) => x(x[0]) + x.bandwidth() / 2)
.attr("y", (x) => y(x[1]) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((x) => x[1]);
},
renderEnvBarChart(cid, envCounts) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const entries = Object.entries(envCounts).filter((x) => x[1] > 0);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📊</span><div>داده‌ای نیست</div></div>';
return;
}
const w = 320,
hgt = 220,
m = { top: 20, right: 15, bottom: 45, left: 30 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleBand()
.domain(entries.map((x) => x[0]))
.range([0, iw])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(entries, (x) => x[1]) || 1])
.nice()
.range([ih, 0]);
g.append("g")
.attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickSize(0))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "8px")
.attr("font-family", "Vazirmatn")
.attr("transform", "rotate(-20)")
.attr("text-anchor", "end");
g.append("g")
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
.selectAll("text")
.attr("fill", "var(--text-muted)")
.attr("font-size", "9px");
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", (x) => x(x[0]))
.attr("y", (x) => y(x[1]))
.attr("width", x.bandwidth())
.attr("height", (x) => ih - y(x[1]))
.attr("fill", "#38a169")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${x[0]}: ${x[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).attr("fill", "#38a169");
hideD3Tooltip();
});
g.selectAll(".bar-label")
.data(entries)
.join("text")
.attr("x", (x) => x(x[0]) + x.bandwidth() / 2)
.attr("y", (x) => y(x[1]) - 5)
.attr("text-anchor", "middle")
.attr("fill", "var(--text)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((x) => x[1]);
},
renderEnvMatrix(cid, envMatrix) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const projects = Object.keys(envMatrix);
if (projects.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>📋</span><div>داده‌ای نیست</div></div>';
return;
}
const envs = projectData.appData.environments || [];
let html =
'<table style="width:100%;border-collapse:collapse;font-size:11px">';
html += '<thead><tr style="background:var(--border-light)">';
html +=
'<th style="padding:8px;text-align:right;border-bottom:2px solid var(--border);font-weight:700">پروژه</th>';
envs.forEach((env) => {
html += `<th style="padding:8px;text-align:center;border-bottom:2px solid var(--border);font-weight:700"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${env.backgroundColor};margin-left:4px"></span>${env.name}</th>`;
});
html += "</tr></thead><tbody>";
projects.forEach((prTitle) => {
html += `<tr style="border-bottom:1px solid var(--border-light)">`;
html += `<td style="padding:6px 8px;font-weight:600">${prTitle}</td>`;
envs.forEach((env) => {
const version = envMatrix[prTitle][env.id];
if (version)
html += `<td style="padding:6px 8px;text-align:center"><span style="font-family:monospace;font-weight:700;color:var(--primary)">v${version}</span></td>`;
else
html +=
'<td style="padding:6px 8px;text-align:center;color:var(--text-muted)">—</td>';
});
html += "</tr>";
});
html += "</tbody></table>";
c.innerHTML = html;
},
renderRiskList(cid, riskList) {
const c = document.getElementById(cid);
if (!c) return;
if (riskList.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
return;
}
const severityOrder = { high: 0, medium: 1, low: 2 };
const sorted = [...riskList].sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
);
const colors = { high: "#dd6b20", medium: "#d69e2e", low: "#3182ce" };
let h = "";
sorted.forEach((r) => {
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
h += "</div>";
});
c.innerHTML = h;
},
};
const RiskAnalysisManager = {
open() {
ModalManager.open("riskAnalysisModal");
setTimeout(() => this.render(), 150);
},
analyzeRisks() {
const risks = [];
const now = new Date();
const thirtyDaysAgo = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
);
const ninetyDaysAgo = new Date(
now.getTime() - 90 * 24 * 60 * 60 * 1000,
);
const envs = projectData.appData.environments || [];
const envIdsWithDeployment = new Set();
projectData.appData.products.forEach((prod) => {
if (!prod.metadata?.owner)
risks.push({
type: "no-owner",
severity: "medium",
icon: "👤",
title: "بدون مالک",
desc: `محصول "${prod.title}" مالک مشخصی ندارد`,
rec: "مالک محصول را مشخص کنید",
});
(prod.projects || []).forEach((pr) => {
(pr.dependencies || []).forEach((depId) => {
if (!ProductManager.findProjectById(depId))
risks.push({
type: "broken",
severity: "critical",
icon: "🔴",
title: "وابستگی شکسته",
desc: `${pr.title} به پروژه ناموجود (${depId}) وابسته است`,
rec: "وابستگی را حذف یا پروژه مقصد را ایجاد کنید",
});
});
const active = ProductManager.getActiveDeployments(pr);
if (active.length === 0)
risks.push({
type: "no-deploy",
severity: "high",
icon: "❌",
title: "بدون استقرار",
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
});
active.forEach((d) => {
envIdsWithDeployment.add(d.environmentId);
if (d.requiredDeployments && d.requiredDeployments.length > 0) {
d.requiredDeployments.forEach((rd) => {
if (!ProductManager.findProjectById(rd.projectId))
risks.push({
type: "broken-req",
severity: "critical",
icon: "🔴",
title: "نیازمندی شکسته",
desc: `${pr.title} به پروژه ناموجود در نیازمندی‌ها ارجاع دارد`,
rec: "نیازمندی را بررسی و اصلاح کنید",
});
});
}
if (!d.releaseNotes) {
const envName = ProductManager.getEnvironmentName(
d.environmentId,
);
risks.push({
type: "no-notes",
severity: "medium",
icon: "📝",
title: "بدون ریلیز نوت",
desc: `${pr.title} v${d.version} در ${envName}`,
rec: "ریلیز نوت را تکمیل کنید",
});
}
if (d.deploymentDate) {
const depDate = new Date(d.deploymentDate);
const daysOld = Math.floor(
(now - depDate) / (1000 * 60 * 60 * 24),
);
const envName = ProductManager.getEnvironmentName(
d.environmentId,
);
if (depDate < ninetyDaysAgo)
risks.push({
type: "stale-critical",
severity: "high",
icon: "⏰",
title: "استقرار بسیار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "فوراً بررسی و به‌روزرسانی کنید",
});
else if (depDate < thirtyDaysAgo)
risks.push({
type: "stale-medium",
severity: "medium",
icon: "⏰",
title: "استقرار قدیمی",
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
rec: "برنامه‌ریزی به‌روزرسانی",
});
}
});
});
});
envs.forEach((env) => {
if (!envIdsWithDeployment.has(env.id))
risks.push({
type: "empty-environment",
severity: "low",
icon: "🌍",
title: "محیط خالی",
desc: `محیط "${env.name}" هیچ استقراری ندارد`,
rec: "در صورت عدم نیاز، محیط را حذف کنید",
});
});
const crit = risks.filter((r) => r.severity === "critical").length;
const high = risks.filter((r) => r.severity === "high").length;
const med = risks.filter((r) => r.severity === "medium").length;
const low = risks.filter((r) => r.severity === "low").length;
let healthScore = 100 - crit * 15 - high * 10 - med * 5 - low * 2;
healthScore = Math.max(0, healthScore);
return {
risks,
crit,
high,
med,
low,
healthScore,
total: risks.length,
};
},
render() {
const c = document.getElementById("riskAnalysisContainer");
if (!c) return;
const d = this.analyzeRisks();
let h = '<div class="dashboard-kpi-row">';
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🎯</div><div class="dashboard-kpi-value">${d.healthScore}%</div><div class="dashboard-kpi-label">امتیاز سلامت</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.crit}</div><div class="dashboard-kpi-label">بحرانی</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟠</div><div class="dashboard-kpi-value">${d.high}</div><div class="dashboard-kpi-label">بالا</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟡</div><div class="dashboard-kpi-value">${d.med}</div><div class="dashboard-kpi-label">متوسط</div></div>`;
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.low}</div><div class="dashboard-kpi-label">کم</div></div>`;
h += "</div>";
h += '<div class="dashboard-charts-grid">';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🍩 توزیع شدت ریسک‌ها</div><div class="dashboard-chart-body" id="riskSeverityChart"></div></div>';
h +=
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نوع ریسک</div><div class="dashboard-chart-body" id="riskTypeChart"></div></div>';
h +=
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسک‌ها و پیشنهادات</div><div class="dashboard-chart-body" id="riskDetailList" style="align-items:flex-start;overflow-y:auto;max-height:350px;width:100%"></div></div>';
h += "</div>";
c.innerHTML = h;
this.renderSeverityDonut("riskSeverityChart", d);
this.renderTypeBarChart("riskTypeChart", d.risks);
this.renderRiskList("riskDetailList", d.risks);
document.getElementById("riskAnalysisFooterStats").innerHTML =
`<span>🎯 سلامت: <span class="stat-highlight">${d.healthScore}%</span></span><span>⚠️ ریسک‌ها: <span class="stat-highlight">${d.total}</span></span><span>🔴 بحرانی: <span class="stat-highlight">${d.crit}</span></span><span>🟠 بالا: <span class="stat-highlight">${d.high}</span></span>`;
document.getElementById("riskAnalysisRefresh").onclick = () =>
this.render();
},
renderSeverityDonut(cid, d) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const data = [
{ label: "بحرانی", value: d.crit, color: "#e53e3e" },
{ label: "بالا", value: d.high, color: "#dd6b20" },
{ label: "متوسط", value: d.med, color: "#d69e2e" },
{ label: "کم", value: d.low, color: "#3182ce" },
].filter((x) => x.value > 0);
if (data.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی نیست</div></div>';
return;
}
const w = 280,
hgt = 220,
r = Math.min(w, hgt) / 2 - 15;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${w / 2},${hgt / 2})`);
const pie = d3
.pie()
.value((x) => x.value)
.sort(null);
const arc = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r);
const arcH = d3
.arc()
.innerRadius(r * 0.55)
.outerRadius(r + 8);
const total = data.reduce((s, x) => s + x.value, 0);
g.selectAll("path")
.data(pie(data))
.join("path")
.attr("d", arc)
.attr("fill", (x) => x.data.color)
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).transition().duration(150).attr("d", arcH(x));
showD3Tooltip(
e,
`${x.data.label}: ${x.data.value} (${Math.round((x.data.value / total) * 100)}%)`,
);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function () {
d3.select(this).transition().duration(150).attr("d", arc);
hideD3Tooltip();
});
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-.2em")
.attr(
"fill",
d.healthScore >= 70
? "#38a169"
: d.healthScore >= 40
? "#dd6b20"
: "#e53e3e",
)
.attr("font-size", "24px")
.attr("font-weight", "800")
.text(`${d.healthScore}%`);
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1.4em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "11px")
.text("امتیاز سلامت");
},
renderTypeBarChart(cid, risks) {
const c = document.getElementById(cid);
if (!c) return;
c.innerHTML = "";
const typeLabels = {
broken: "وابستگی شکسته",
"broken-req": "نیازمندی شکسته",
"no-deploy": "بدون استقرار",
"stale-critical": "استقرار بسیار قدیمی",
"stale-medium": "استقرار قدیمی",
"no-notes": "بدون ریلیز نوت",
"no-owner": "بدون مالک",
"empty-environment": "محیط خالی",
};
const typeColors = {
broken: "#e53e3e",
"broken-req": "#e53e3e",
"no-deploy": "#dd6b20",
"stale-critical": "#dd6b20",
"stale-medium": "#d69e2e",
"no-notes": "#d69e2e",
"no-owner": "#d69e2e",
"empty-environment": "#3182ce",
};
const typeCounts = {};
risks.forEach((r) => {
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
});
const entries = Object.entries(typeCounts).sort(
(a, b) => b[1] - a[1],
);
if (entries.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>ریسکی نیست</div></div>';
return;
}
const bh = 28,
gap = 6;
const w = 320,
hgt = entries.length * (bh + gap) + 30;
const m = { top: 10, right: 35, bottom: 10, left: 130 };
const iw = w - m.left - m.right,
ih = hgt - m.top - m.bottom;
const svg = d3
.select(c)
.append("svg")
.attr("width", w)
.attr("height", hgt);
const g = svg
.append("g")
.attr("transform", `translate(${m.left},${m.top})`);
const x = d3
.scaleLinear()
.domain([0, d3.max(entries, (x) => x[1]) || 1])
.range([0, iw]);
const y = d3
.scaleBand()
.domain(entries.map((x) => x[0]))
.range([0, ih])
.padding(0.2);
g.selectAll("rect")
.data(entries)
.join("rect")
.attr("x", 0)
.attr("y", (x) => y(x[0]))
.attr("width", (x) => x(x[1]))
.attr("height", y.bandwidth())
.attr("fill", (x) => typeColors[x[0]] || "#718096")
.attr("rx", 4)
.style("cursor", "pointer")
.on("mouseover", function (e, x) {
d3.select(this).attr("fill", "var(--accent)");
showD3Tooltip(e, `${typeLabels[x[0]] || x[0]}: ${x[1]}`);
})
.on("mousemove", (e) => showD3Tooltip(e, ""))
.on("mouseout", function (e, x) {
d3.select(this).attr("fill", typeColors[x[0]] || "#718096");
hideD3Tooltip();
});
g.selectAll(".label")
.data(entries)
.join("text")
.attr("x", -8)
.attr("y", (x) => y(x[0]) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("fill", "var(--text)")
.attr("font-size", "9px")
.attr("font-weight", "600")
.text((x) => typeLabels[x[0]] || x[0]);
g.selectAll(".value")
.data(entries)
.join("text")
.attr("x", (x) => x(x[1]) + 5)
.attr("y", (x) => y(x[0]) + y.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("fill", "var(--text-muted)")
.attr("font-size", "10px")
.attr("font-weight", "700")
.text((x) => x[1]);
},
renderRiskList(cid, risks) {
const c = document.getElementById(cid);
if (!c) return;
if (risks.length === 0) {
c.innerHTML =
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
return;
}
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
const sorted = [...risks].sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
);
const colors = {
critical: "#e53e3e",
high: "#dd6b20",
medium: "#d69e2e",
low: "#3182ce",
};
let h = "";
sorted.forEach((r) => {
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
h += "</div>";
});
c.innerHTML = h;
},
};
/* ==================== EVENT BINDINGS ==================== */
document
.getElementById("projectDetailDeleteBtn")
.addEventListener("click", () => {
if (ProductManager.currentDetailProductId)
ProductManager.removeProduct(ProductManager.currentDetailProductId);
});
document
.getElementById("saveProductEditBtn")
.addEventListener("click", () => ProductManager.saveProductEdit());
document
.getElementById("saveNewProjectBtn")
.addEventListener("click", () => ProductManager.saveNewProject());
document
.getElementById("saveProjEditBtn")
.addEventListener("click", () => ProductManager.saveProjectEdit());
document
.getElementById("saveDeploymentBtn")
.addEventListener("click", () => ProductManager.saveDeployment());
document
.getElementById("saveEnvEditBtn")
.addEventListener("click", () => EnvironmentManager.saveEdit());
document
.getElementById("saveNewProductBtn")
.addEventListener("click", () => ProductManager.createNewProduct());
document
.getElementById("saveNewEnvBtn")
.addEventListener("click", () => EnvironmentManager.saveNewEnv());
document
.getElementById("newEnvBgColor")
.addEventListener("input", (e) => {
document.getElementById("newEnvBgColorText").textContent =
e.target.value;
});
document
.getElementById("newEnvTextColor")
.addEventListener("input", (e) => {
document.getElementById("newEnvTextColorText").textContent =
e.target.value;
});
document
.getElementById("editEnvBgColor")
.addEventListener("input", (e) => {
document.getElementById("editEnvBgColorText").textContent =
e.target.value;
EnvironmentManager.updateEditPreview();
});
document
.getElementById("editEnvTextColor")
.addEventListener("input", (e) => {
document.getElementById("editEnvTextColorText").textContent =
e.target.value;
EnvironmentManager.updateEditPreview();
});
document
.getElementById("galaxyResetView")
.addEventListener("click", () => {
if (GalaxyChartManager.svg && GalaxyChartManager.zoomBehavior)
GalaxyChartManager.svg
.transition()
.duration(500)
.call(GalaxyChartManager.zoomBehavior.transform, d3.zoomIdentity);
});
document
.getElementById("galaxyInfoClose")
.addEventListener("click", () => {
document
.getElementById("galaxyInfoPanel")
.classList.remove("visible");
});
document
.getElementById("galaxyCollapseAll")
.addEventListener("click", () => {
GalaxyChartManager.collapseAllProducts();
});
document
.getElementById("galaxyExpandAll")
.addEventListener("click", () => {
GalaxyChartManager.expandAllProducts();
});
document
.getElementById("galaxyExportPNG")
.addEventListener("click", () => {
showToast("📷 خروجی ذخیره شد", "success");
});
function handleToolSelect(tool, group) {
const ah = ActionRegistry[tool.action] || ActionRegistry.defaultAction;
ah(tool, group);
}
const themeToggle = document.getElementById("themeToggle"),
themeIcon = document.getElementById("themeIcon"),
htmlEl = document.documentElement;
const savedTheme = localStorage.getItem("theme") || "light";
htmlEl.setAttribute("data-theme", savedTheme);
if (themeIcon)
themeIcon.textContent = savedTheme === "dark" ? "☀️" : "🌙";
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const c = htmlEl.getAttribute("data-theme");
const n = c === "dark" ? "light" : "dark";
htmlEl.setAttribute("data-theme", n);
localStorage.setItem("theme", n);
if (themeIcon) themeIcon.textContent = n === "dark" ? "☀️" : "🌙";
});
}
const sidebarToggle = document.getElementById("sidebarToggle"),
sidebar = document.getElementById("sidebar"),
sidebarIcon = document.getElementById("sidebarIcon");
function updateSidebarIcon() {
if (!sidebarIcon) return;
sidebarIcon.textContent = sidebar.classList.contains("collapsed")
? "☰"
: "✕";
}
if (sidebarToggle) {
sidebarToggle.addEventListener("click", () => {
sidebar.classList.toggle("collapsed");
updateSidebarIcon();
});
}
updateSidebarIcon();
const sidebarBody = document.getElementById("sidebarBody");
function renderToolGroups() {
sidebarBody.innerHTML = "";
projectData.toolGroups.forEach((group) => {
const ge = document.createElement("div");
ge.className = "sidebar-group";
const he = document.createElement("div");
he.className = "sidebar-group-header";
he.innerHTML = `<div class="group-icon"><span>${group.icon || ""}</span><span>${group.title}</span></div><span class="group-arrow">▼</span>`;
const be = document.createElement("div");
be.className = "sidebar-group-body";
group.tools.forEach((tool) => {
const btn = document.createElement("button");
btn.className = "sidebar-btn";
btn.title = tool.description;
btn.innerHTML = `<span style="width:20px;text-align:center">${tool.icon || "🔧"}</span><span style="flex:1">${tool.title}</span>`;
btn.addEventListener("click", () => handleToolSelect(tool, group));
be.appendChild(btn);
});
he.addEventListener("click", () => ge.classList.toggle("collapsed"));
ge.appendChild(he);
ge.appendChild(be);
sidebarBody.appendChild(ge);
});
}
function openSettingsModal() {
document.getElementById("settingProjectTitle").value =
projectData.projectTitle;
document.getElementById("settingVersion").value = projectData.version;
document.getElementById("settingDescription").value =
projectData.description;
document.getElementById("settingDeveloper").value =
projectData.developer;
document.getElementById("settingCompany").value = projectData.company;
document.getElementById("settingWebhookUrl").value =
projectData.webhook.url;
document.getElementById("settingWebhookClientId").value =
projectData.webhook.clientId;
document.getElementById("settingWebhookEnabled").checked =
projectData.webhook.enabled;
WebhookManager.updateStatusUI();
ModalManager.open("settingsModal");
}
[
"settingWebhookUrl",
"settingWebhookClientId",
"settingWebhookEnabled",
].forEach((id) => {
document.getElementById(id).addEventListener("input", () => {
WebhookManager.setConfig({
url: document.getElementById("settingWebhookUrl").value,
clientId: document.getElementById("settingWebhookClientId").value,
enabled: document.getElementById("settingWebhookEnabled").checked,
});
});
});
document
.getElementById("saveSettingsBtn")
.addEventListener("click", () => {
WebhookManager.setConfig({
url: document.getElementById("settingWebhookUrl").value.trim(),
clientId: document
.getElementById("settingWebhookClientId")
.value.trim(),
enabled: document.getElementById("settingWebhookEnabled").checked,
});
ModalManager.close("settingsModal");
showToast("تنظیمات ذخیره شد", "success");
});
function updateProjectUI() {
document.title = projectData.projectTitle;
document.getElementById("headerTitle").textContent =
projectData.projectTitle;
document.getElementById("headerSubtitle").textContent =
projectData.description;
document.getElementById("appFooter").innerHTML =
`<div><span class="footer-brand">طراح و توسعه‌دهنده:</span> ${projectData.developer} <span style="margin:0 10px">|</span><span class="footer-brand">شرکت:</span> ${projectData.company}</div><div><span class="footer-brand">نسخه:</span> ${projectData.version}</div>`;
}
/* ==================== INIT ==================== */
ModalManager.init();
renderToolGroups();
updateProjectUI();
WebhookManager.updateStatusUI();
ToolbarManager.init();
SectionCollapseManager.init();
BoardRenderer.render();
HistoryManager.init();
AutosaveManager.checkRecovery();
window.addEventListener("beforeunload", () => {
AutosaveManager.save();
});
setTimeout(
() =>
showToast(
`${projectData.projectTitle} (نسخه ${projectData.version}) آماده است`,
"success",
),
500,
);
</script>
</body>
</html>