DeployPlanner/main.html
2025-09-14 19:53:31 +02:00

741 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deployment Planner</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom scrollbar for a better dark-theme experience */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937; /* gray-800 */
}
::-webkit-scrollbar-thumb {
background: #374151; /* gray-700 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563; /* gray-600 */
}
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.8);
}
.hidden {
display: none;
}
</style>
</head>
<body class="bg-gray-900 text-gray-200 min-h-screen font-sans antialiased">
<div id="app-container">
<!-- App content will be rendered here by JavaScript -->
</div>
<!-- Modals Container -->
<div id="modal-container">
<!-- Modals will be rendered here -->
</div>
<script>
// --- DATA MODELS & CONSTANTS ---
const ActionType = {
COMPONENT: "Déploiement d'un composant technique",
SECRET: "Changement de secret",
DMN: "Changement de DMN",
DDL: "DDL",
DML: "DML"
};
const ActionMoment = {
BEFORE: "Avant la release",
DURING: "Pendant la release",
AFTER: "Après la release"
};
const ItemStatus = {
TODO: "À faire",
IN_PROGRESS: "En cours",
DONE: "Terminé",
FAILED: "Échoué",
NA: "N/A"
};
const TABS = [
{ id: 'actions', name: "Liste d'actions" },
{ id: 'quality', name: 'Quality Checks' },
{ id: 'overview', name: 'Aperçus (Jira/BPMN)' },
{ id: 'detailed', name: 'Plan Détaillé' },
];
// --- APPLICATION STATE ---
let state = {
view: 'list', // 'list' or 'detail'
deploymentPlans: [],
selectedPlan: null,
activeTab: 'actions',
isActionModalOpen: false,
isStatusModalOpen: false,
currentAction: null,
lastSaveMessage: 'Jamais'
};
// --- UTILITY FUNCTIONS ---
function formatDate(isoString) {
return new Date(isoString).toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
}
function getSafeUrl(url) {
if (url && (url.startsWith('https://') || url.startsWith('http://'))) {
return url;
}
return 'about:blank';
}
function createEmptyStatusGrid() {
return {
onss: { int: ItemStatus.NA, acc: ItemStatus.NA, prod: ItemStatus.NA },
onem: { int: ItemStatus.NA, acc: ItemStatus.NA, prod: ItemStatus.NA },
};
}
// --- RENDERING ENGINE ---
const appContainer = document.getElementById('app-container');
const modalContainer = document.getElementById('modal-container');
function render() {
// Unbind all events before re-rendering to avoid duplicates
unbindEvents();
appContainer.innerHTML = `
<header class="bg-gray-800/50 backdrop-blur-sm sticky top-0 z-20 shadow-lg border-b border-sky-500/20">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center space-x-3">
<svg class="h-8 w-8 text-sky-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-1.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h4.5M15 3.75l3 3m0 0l-3 3m3-3h-9" /></svg>
<h1 class="text-2xl font-bold text-gray-100">Deployment Planner</h1>
</div>
${state.view === 'detail' ? `
<button id="back-to-list-btn" class="flex items-center space-x-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg shadow-md transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" /></svg>
<span>Retour à la liste</span>
</button>
` : ''}
</div>
</div>
</header>
<main class="container mx-auto p-4 sm:p-6 lg:p-8">
${state.view === 'list' ? renderListView() : renderDetailView()}
</main>
`;
modalContainer.innerHTML = `
${renderActionModal()}
${renderStatusModal()}
`;
// Re-bind events after rendering
bindEvents();
}
// --- VIEW TEMPLATES ---
function renderListView() {
const plansHtml = state.deploymentPlans.map(plan => `
<div class="bg-gray-800 rounded-xl shadow-lg overflow-hidden border border-gray-700 hover:border-sky-500 transition-all duration-300 transform hover:-translate-y-1">
<div class="p-6">
<h3 class="text-xl font-bold text-sky-400 mb-2">${plan.name}</h3>
<p class="text-sm text-gray-400">Dernière modification: ${formatDate(plan.lastModified)}</p>
<p class="text-sm text-gray-400 mt-1">Actions: ${plan.actions.length}</p>
</div>
<div class="bg-gray-700/50 p-4 flex justify-end space-x-3">
<button data-action="open-plan" data-plan-id="${plan.id}" class="px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded-lg text-sm font-semibold transition-colors duration-200">Ouvrir</button>
<button data-action="delete-plan" data-plan-id="${plan.id}" class="p-2 bg-red-800/50 hover:bg-red-700/50 text-red-400 hover:text-white rounded-full transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.58.22-2.365.468a.75.75 0 10.23 1.482l.149-.046A12.705 12.705 0 0110 8a12.705 12.705 0 015.986-2.299l.149.046a.75.75 0 10.23-1.482A13.455 13.455 0 0014 4.193v-.443A2.75 2.75 0 0011.25 1h-2.5zM10 10a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 10zM8.75 11.5a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zM11.25 11.5a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z" clip-rule="evenodd" /></svg>
</button>
</div>
</div>
`).join('');
return `
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-semibold">Plans de Déploiement</h2>
<button id="new-plan-btn" class="flex items-center space-x-2 bg-sky-500 hover:bg-sky-600 text-white font-bold py-2 px-4 rounded-lg shadow-lg hover:shadow-sky-500/50 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" /></svg>
<span>Nouveau Plan</span>
</button>
</div>
${state.deploymentPlans.length > 0 ? `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">${plansHtml}</div>` : `
<div class="text-center py-16 px-6 bg-gray-800 rounded-lg border-2 border-dashed border-gray-700">
<svg class="mx-auto h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path></svg>
<h3 class="mt-2 text-lg font-medium text-gray-300">Aucun plan de déploiement</h3>
<p class="mt-1 text-sm text-gray-500">Commencez par créer votre premier plan.</p>
</div>
`}
`;
}
function renderDetailView() {
const plan = state.selectedPlan;
if (!plan) return '<div>Erreur: Aucun plan sélectionné.</div>';
const tabsHtml = TABS.map(tab => `
<button data-action="change-tab" data-tab-id="${tab.id}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200
${state.activeTab === tab.id ? 'border-sky-500 text-sky-400' : 'border-transparent text-gray-400 hover:text-gray-200 hover:border-gray-500'}">
${tab.name}
</button>
`).join('');
return `
<div class="space-y-8">
<!-- Plan Header -->
<div class="p-6 bg-gray-800 rounded-xl shadow-lg border border-gray-700 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<label for="plan-name" class="text-sm font-medium text-gray-400">Nom du Plan</label>
<input data-action="update-plan-field" data-field="name" id="plan-name" type="text" value="${plan.name}" class="text-3xl font-bold bg-transparent border-0 border-b-2 border-gray-600 focus:border-sky-500 focus:ring-0 transition w-full sm:w-auto text-gray-100 p-1" />
</div>
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-400 whitespace-nowrap">Dernière sauvegarde : ${state.lastSaveMessage}</p>
<button id="save-plan-btn" class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg transition-colors duration-200">Sauvegarder</button>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-700">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">${tabsHtml}</nav>
</div>
<!-- Tab Content -->
<div class="bg-gray-800/50 p-6 rounded-lg border border-gray-700">
${renderTabContent()}
</div>
</div>
`;
}
function renderTabContent() {
switch(state.activeTab) {
case 'actions': return renderActionsTab();
case 'quality': return renderQualityTab();
case 'overview': return renderOverviewTab();
case 'detailed': return renderDetailedTab();
default: return '';
}
}
// --- TAB CONTENT RENDERERS ---
function renderActionsTab() {
const actions = state.selectedPlan.actions;
const actionsHtml = actions.map((action, index) => `
<tr class="hover:bg-gray-700/50">
<td class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-100">${action.sujet}</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-300">${action.type}</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-300">${action.moment}</td>
<td class="px-4 py-4 text-center">
<button data-action="open-status-modal" data-action-id="${action.id}" class="p-2 bg-gray-700 hover:bg-gray-600 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 text-sky-400"><path fill-rule="evenodd" d="M4.25 2A2.25 2.25 0 002 4.25v2.5A2.25 2.25 0 004.25 9h2.5A2.25 2.25 0 009 6.75v-2.5A2.25 2.25 0 006.75 2h-2.5zm0 9A2.25 2.25 0 002 13.25v2.5A2.25 2.25 0 004.25 18h2.5A2.25 2.25 0 009 15.75v-2.5A2.25 2.25 0 006.75 11h-2.5zm9-9A2.25 2.25 0 0011 4.25v2.5A2.25 2.25 0 0013.25 9h2.5A2.25 2.25 0 0018 6.75v-2.5A2.25 2.25 0 0015.75 2h-2.5zm0 9A2.25 2.25 0 0011 13.25v2.5A2.25 2.25 0 0013.25 18h2.5A2.25 2.25 0 0018 15.75v-2.5A2.25 2.25 0 0015.75 11h-2.5z" clip-rule="evenodd" /></svg>
</button>
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-sky-400 space-x-3">
${action.url ? `<a href="${action.url}" target="_blank" class="hover:underline">URL</a>` : ''}
${action.jira ? `<a href="${action.jira}" target="_blank" class="hover:underline">Jira</a>` : ''}
</td>
<td class="px-4 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button data-action="edit-action" data-action-id="${action.id}" class="text-sky-400 hover:text-sky-300">Modifier</button>
<button data-action="remove-action" data-index="${index}" class="text-red-500 hover:text-red-400">Suppr.</button>
</td>
</tr>
`).join('');
return `
<h3 class="text-xl font-semibold mb-4">Liste d'actions</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Sujet</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Type</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Moment</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Statut</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Liens</th>
<th class="relative px-4 py-3"><span class="sr-only">Edit</span></th>
</tr>
</thead>
<tbody class="bg-gray-800/50 divide-y divide-gray-700">${actionsHtml}</tbody>
</table>
</div>
<div class="mt-4">
<button data-action="add-action" class="text-sky-400 hover:text-sky-300 font-semibold">+ Ajouter une action</button>
</div>
`;
}
function renderQualityTab() {
const plan = state.selectedPlan;
const itemStatusOptions = Object.values(ItemStatus).map(s => `<option value="${s}">${s}</option>`).join('');
const businessChecks = plan.qualityChecks.business.map((check, i) => `
<div class="flex items-center gap-3 p-2 bg-gray-900/50 rounded-md">
<input type="text" value="${check.sujet}" data-action="update-quality-check" data-type="business" data-index="${i}" data-field="sujet" class="flex-grow bg-transparent focus:ring-0 border-0 border-b border-gray-600 focus:border-sky-500 transition">
<select data-action="update-quality-check" data-type="business" data-index="${i}" data-field="statut" class="bg-gray-700 border-gray-600 rounded-md focus:ring-sky-500 focus:border-sky-500 text-sm">
${Object.values(ItemStatus).map(s => `<option value="${s}" ${check.statut === s ? 'selected' : ''}>${s}</option>`).join('')}
</select>
<button data-action="remove-quality-check" data-type="business" data-index="${i}" class="text-red-500 hover:text-red-400 p-1"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /></svg></button>
</div>
`).join('');
const technicalChecks = plan.qualityChecks.technical.map((check, i) => `
<div class="flex items-center gap-3 p-2 bg-gray-900/50 rounded-md">
<input type="text" value="${check.sujet}" data-action="update-quality-check" data-type="technical" data-index="${i}" data-field="sujet" class="flex-grow bg-transparent focus:ring-0 border-0 border-b border-gray-600 focus:border-sky-500 transition">
<select data-action="update-quality-check" data-type="technical" data-index="${i}" data-field="statut" class="bg-gray-700 border-gray-600 rounded-md focus:ring-sky-500 focus:border-sky-500 text-sm">
${Object.values(ItemStatus).map(s => `<option value="${s}" ${check.statut === s ? 'selected' : ''}>${s}</option>`).join('')}
</select>
<button data-action="remove-quality-check" data-type="technical" data-index="${i}" class="text-red-500 hover:text-red-400 p-1"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /></svg></button>
</div>
`).join('');
return `
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Business Validations -->
<div>
<h4 class="text-lg font-semibold mb-3">Validations Business (ACC)</h4>
<div class="space-y-3">${businessChecks}</div>
<button data-action="add-quality-check" data-type="business" class="text-sky-400 hover:text-sky-300 font-semibold mt-2">+ Ajouter une validation business</button>
</div>
<!-- Technical Validations -->
<div>
<h4 class="text-lg font-semibold mb-3">Validations Techniques</h4>
<div class="space-y-3">${technicalChecks}</div>
<button data-action="add-quality-check" data-type="technical" class="text-sky-400 hover:text-sky-300 font-semibold mt-2">+ Ajouter une validation technique</button>
</div>
</div>
`;
}
function renderOverviewTab() {
const plan = state.selectedPlan;
return `
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- JIRA -->
<div>
<h3 class="text-xl font-semibold mb-4">Aperçu Tickets JIRA</h3>
<input type="url" value="${plan.jiraOverviewUrl}" data-action="update-plan-field" data-field="jiraOverviewUrl" placeholder="https://your.jira.com/..." class="w-full bg-gray-900 border-gray-600 rounded-md mb-4">
<iframe src="${getSafeUrl(plan.jiraOverviewUrl)}" class="w-full h-96 rounded-lg border border-gray-700"></iframe>
</div>
<!-- BPMN -->
<div>
<h3 class="text-xl font-semibold mb-4">Aperçu Visuel (BPMN)</h3>
<input type="url" value="${plan.bpmnUrl}" data-action="update-plan-field" data-field="bpmnUrl" placeholder="https://app.diagrams.net/..." class="w-full bg-gray-900 border-gray-600 rounded-md mb-4">
<iframe src="${getSafeUrl(plan.bpmnUrl)}" class="w-full h-96 rounded-lg border border-gray-700"></iframe>
</div>
</div>
`;
}
function renderDetailedTab() {
const plan = state.selectedPlan;
const actionOptions = plan.actions.map(act => `<option value="${act.id}">${act.sujet}</option>`).join('');
let html = '<div class="space-y-8">';
for(const env of ['acc', 'prod']) {
html += `<div><h3 class="text-2xl font-bold uppercase mb-4 border-b-2 border-sky-500 pb-2">${env}</h3>`;
html += '<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">';
for(const tenant of ['onss', 'onem']) {
html += `<div class="space-y-4"><h4 class="text-xl font-semibold text-sky-400 uppercase">${tenant}</h4>`;
plan.detailedPlan[env][tenant].forEach((section, i) => {
const disabled = section.isFinished ? 'disabled' : '';
const detailedActionsHtml = section.actions.map((dAction, j) => `
<div class="grid grid-cols-1 md:grid-cols-3 gap-2 p-2 bg-gray-800 rounded">
<select data-action="update-detailed-action" data-env="${env}" data-tenant="${tenant}" data-section-index="${i}" data-action-index="${j}" data-field="actionId" class="bg-gray-700 border-gray-600 rounded-md text-sm" ${disabled}>
${plan.actions.map(act => `<option value="${act.id}" ${dAction.actionId === act.id ? 'selected':''}>${act.sujet}</option>`).join('')}
</select>
<input type="text" value="${dAction.responsable}" data-action="update-detailed-action" data-env="${env}" data-tenant="${tenant}" data-section-index="${i}" data-action-index="${j}" data-field="responsable" placeholder="Responsable" class="bg-gray-700 border-gray-600 rounded-md text-sm" ${disabled}>
<div class="flex items-center">
<input type="datetime-local" value="${dAction.moment}" data-action="update-detailed-action" data-env="${env}" data-tenant="${tenant}" data-section-index="${i}" data-action-index="${j}" data-field="moment" class="bg-gray-700 border-gray-600 rounded-md text-sm w-full" ${disabled}>
<button data-action="remove-detailed-action" data-env="${env}" data-tenant="${tenant}" data-section-index="${i}" data-action-index="${j}" class="ml-2 text-red-500 hover:text-red-400" ${disabled}><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /></svg></button>
</div>
</div>
`).join('');
html += `
<div class="bg-gray-900/50 p-4 rounded-lg border border-gray-700">
<div class="flex justify-between items-center mb-3">
<input type="text" value="${section.title}" data-action="update-detailed-section" data-env="${env}" data-tenant="${tenant}" data-index="${i}" data-field="title" class="text-lg font-bold bg-transparent focus:ring-0 border-0 border-b border-gray-600 focus:border-sky-500 transition" ${disabled}>
<div class="flex items-center space-x-2">
<button data-action="duplicate-detailed-section" data-env="${env}" data-tenant="${tenant}" data-index="${i}" title="Dupliquer" class="p-1 text-gray-400 hover:text-white" ${disabled}><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.121A1.5 1.5 0 0117 6.621V16.5a1.5 1.5 0 01-1.5 1.5h-7A1.5 1.5 0 017 16.5v-13z" /><path d="M4.5 6A1.5 1.5 0 003 7.5v10A1.5 1.5 0 004.5 19h7a1.5 1.5 0 001.5-1.5v-1.336a.75.75 0 00-1.5 0V17.5a.5.5 0 01-.5.5h-7a.5.5 0 01-.5-.5v-10a.5.5 0 01.5-.5H5V6a.5.5 0 01-.5-.5z" /></svg></button>
<button data-action="remove-detailed-section" data-env="${env}" data-tenant="${tenant}" data-index="${i}" title="Supprimer" class="p-1 text-red-500 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.58.22-2.365.468a.75.75 0 10.23 1.482l.149-.046A12.705 12.705 0 0110 8a12.705 12.705 0 015.986-2.299l.149.046a.75.75 0 10.23-1.482A13.455 13.455 0 0014 4.193v-.443A2.75 2.75 0 0011.25 1h-2.5zM10 10a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 10zM8.75 11.5a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zM11.25 11.5a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z" clip-rule="evenodd" /></svg></button>
<div class="flex items-center">
<input type="checkbox" ${section.isFinished ? 'checked':''} data-action="update-detailed-section" data-env="${env}" data-tenant="${tenant}" data-index="${i}" data-field="isFinished" class="h-4 w-4 rounded bg-gray-700 border-gray-600 text-sky-600 focus:ring-sky-500">
<label class="ml-2 text-sm text-gray-400">Terminé</label>
</div>
</div>
</div>
<div class="space-y-2">${detailedActionsHtml}</div>
<button data-action="add-detailed-action" data-env="${env}" data-tenant="${tenant}" data-section-index="${i}" class="text-sky-400 hover:text-sky-300 font-semibold mt-3 text-sm" ${disabled}>+ Ajouter une action détaillée</button>
</div>
`;
});
html += `<button data-action="add-detailed-section" data-env="${env}" data-tenant="${tenant}" class="w-full text-center py-2 border-2 border-dashed border-gray-700 hover:bg-gray-700/50 rounded-lg transition">+ Ajouter un plan détaillé</button></div>`;
}
html += '</div></div>';
}
html += '</div>';
return html;
}
// --- MODAL TEMPLATES ---
function renderActionModal() {
if (!state.isActionModalOpen) return '';
const action = state.currentAction;
const actionTypeOptions = Object.values(ActionType).map(t => `<option value="${t}" ${action.type === t ? 'selected':''}>${t}</option>`).join('');
const actionMomentOptions = Object.values(ActionMoment).map(m => `<option value="${m}" ${action.moment === m ? 'selected':''}>${m}</option>`).join('');
return `
<div id="action-modal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg shadow-2xl p-8 w-full max-w-2xl border border-gray-700" id="action-modal-content">
<h3 class="text-2xl font-bold mb-6">${action.id ? 'Modifier' : 'Ajouter'} une Action</h3>
<form id="action-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-400">Sujet</label>
<input name="sujet" type="text" value="${action.sujet}" required class="mt-1 w-full bg-gray-900 border-gray-600 rounded-md">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-400">Type</label>
<select name="type" class="mt-1 w-full bg-gray-900 border-gray-600 rounded-md">${actionTypeOptions}</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-400">Moment</label>
<select name="moment" class="mt-1 w-full bg-gray-900 border-gray-600 rounded-md">${actionMomentOptions}</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400">URL</label>
<input name="url" type="url" value="${action.url}" class="mt-1 w-full bg-gray-900 border-gray-600 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-400">JIRA</label>
<input name="jira" type="url" value="${action.jira}" class="mt-1 w-full bg-gray-900 border-gray-600 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-400">Remarque</label>
<textarea name="remarque" rows="3" class="mt-1 w-full bg-gray-900 border-gray-600 rounded-md">${action.remarque}</textarea>
</div>
<div class="flex justify-end space-x-4 pt-4">
<button type="button" data-action="close-action-modal" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg">Annuler</button>
<button type="submit" class="px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white font-bold rounded-lg">Sauvegarder</button>
</div>
</form>
</div>
</div>
`;
}
function renderStatusModal() {
if (!state.isStatusModalOpen) return '';
const action = state.currentAction;
let gridHtml = '';
for(const env of ['int', 'acc', 'prod']) {
gridHtml += `<div class="font-bold text-right pr-4 uppercase text-gray-400">${env}</div>`;
for(const tenant of ['onss', 'onem']) {
gridHtml += `<select data-action="update-status" data-tenant="${tenant}" data-env="${env}" class="bg-gray-700 border-gray-600 rounded-md focus:ring-sky-500 focus:border-sky-500 text-sm">`;
gridHtml += Object.values(ItemStatus).map(s => `<option value="${s}" ${action.statut[tenant][env] === s ? 'selected' : ''}>${s}</option>`).join('');
gridHtml += `</select>`;
}
}
return `
<div id="status-modal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg shadow-2xl p-8 border border-gray-700" id="status-modal-content">
<h3 class="text-2xl font-bold mb-2">Statuts pour :</h3>
<p class="text-sky-400 mb-6">${action.sujet}</p>
<div class="grid grid-cols-3 gap-x-12 gap-y-6">
<div></div>
<div class="font-bold text-center text-gray-400">ONSS</div>
<div class="font-bold text-center text-gray-400">ONEM</div>
${gridHtml}
</div>
<div class="flex justify-end mt-8">
<button data-action="close-status-modal" class="px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white font-bold rounded-lg">Fermer</button>
</div>
</div>
</div>
`;
}
// --- EVENT HANDLING ---
// Centralized event handler
function handleEvent(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const ds = target.dataset; // shortcut for dataset
// --- PLAN LIST ACTIONS ---
if (action === 'open-plan') {
const plan = state.deploymentPlans.find(p => p.id === ds.planId);
if (plan) {
// Deep copy for editing
state.selectedPlan = JSON.parse(JSON.stringify(plan));
state.view = 'detail';
render();
}
}
if (action === 'delete-plan') {
state.deploymentPlans = state.deploymentPlans.filter(p => p.id !== ds.planId);
console.log(`Plan with id ${ds.planId} deleted.`);
render();
}
// --- HEADER & DETAIL VIEW ACTIONS ---
if (action === 'back-to-list') {
state.selectedPlan = null;
state.view = 'list';
state.activeTab = 'actions';
render();
}
if (action === 'new-plan') {
handleCreateNewPlan();
}
if (action === 'save-plan') {
handleSavePlan();
}
if (action === 'update-plan-field') {
state.selectedPlan[ds.field] = event.target.value;
// Re-render only if it's a URL to update iframe
if(ds.field === 'jiraOverviewUrl' || ds.field === 'bpmnUrl') render();
}
if (action === 'change-tab') {
state.activeTab = ds.tabId;
render();
}
// --- ACTIONS TAB ---
if (action === 'add-action') {
state.currentAction = { id: '', sujet: '', type: ActionType.COMPONENT, moment: ActionMoment.DURING, statut: createEmptyStatusGrid(), remarque: '', url: '', jira: '' };
state.isActionModalOpen = true;
render();
}
if (action === 'edit-action') {
const actionToEdit = state.selectedPlan.actions.find(a => a.id === ds.actionId);
if (actionToEdit) {
state.currentAction = JSON.parse(JSON.stringify(actionToEdit));
state.isActionModalOpen = true;
render();
}
}
if (action === 'remove-action') {
state.selectedPlan.actions.splice(parseInt(ds.index, 10), 1);
render();
}
// --- QUALITY TAB ---
if (action === 'add-quality-check') {
const newCheck = { id: crypto.randomUUID(), sujet: '', statut: ItemStatus.TODO };
state.selectedPlan.qualityChecks[ds.type].push(newCheck);
render();
}
if (action === 'remove-quality-check') {
state.selectedPlan.qualityChecks[ds.type].splice(parseInt(ds.index, 10), 1);
render();
}
if (action === 'update-quality-check') {
state.selectedPlan.qualityChecks[ds.type][ds.index][ds.field] = event.target.value;
}
// --- DETAILED TAB ---
if (action === 'add-detailed-section') {
const newSection = { id: crypto.randomUUID(), title: `Nouveau Plan ${ds.env.toUpperCase()} ${ds.tenant.toUpperCase()}`, isFinished: false, actions: [] };
state.selectedPlan.detailedPlan[ds.env][ds.tenant].push(newSection);
render();
}
if (action === 'remove-detailed-section') {
state.selectedPlan.detailedPlan[ds.env][ds.tenant].splice(parseInt(ds.index), 1);
render();
}
if(action === 'duplicate-detailed-section') {
const originalSection = state.selectedPlan.detailedPlan[ds.env][ds.tenant][ds.index];
const newSection = JSON.parse(JSON.stringify(originalSection));
newSection.id = crypto.randomUUID();
newSection.title = `${originalSection.title} (Copie)`;
newSection.isFinished = false;
state.selectedPlan.detailedPlan[ds.env][ds.tenant].splice(parseInt(ds.index) + 1, 0, newSection);
render();
}
if(action === 'update-detailed-section') {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
state.selectedPlan.detailedPlan[ds.env][ds.tenant][ds.index][ds.field] = value;
if(ds.field === 'isFinished') render(); // re-render to disable/enable inputs
}
if(action === 'add-detailed-action') {
const newAction = { id: crypto.randomUUID(), actionId: state.selectedPlan?.actions[0]?.id || '', responsable: '', moment: new Date().toISOString().slice(0, 16) };
state.selectedPlan.detailedPlan[ds.env][ds.tenant][ds.sectionIndex].actions.push(newAction);
render();
}
if(action === 'remove-detailed-action') {
state.selectedPlan.detailedPlan[ds.env][ds.tenant][ds.sectionIndex].actions.splice(parseInt(ds.actionIndex), 1);
render();
}
if(action === 'update-detailed-action') {
state.selectedPlan.detailedPlan[ds.env][ds.tenant][ds.sectionIndex].actions[ds.actionIndex][ds.field] = event.target.value;
}
// --- MODAL ACTIONS ---
if (action === 'close-action-modal' || event.target.id === 'action-modal') {
state.isActionModalOpen = false;
state.currentAction = null;
render();
}
if (action === 'close-status-modal' || event.target.id === 'status-modal') {
const actionToUpdate = state.selectedPlan.actions.find(a => a.id === state.currentAction.id);
if(actionToUpdate) actionToUpdate.statut = state.currentAction.statut;
state.isStatusModalOpen = false;
state.currentAction = null;
render();
}
if(action === 'open-status-modal') {
const actionToUpdate = state.selectedPlan.actions.find(a => a.id === ds.actionId);
if(actionToUpdate) {
state.currentAction = JSON.parse(JSON.stringify(actionToUpdate));
state.isStatusModalOpen = true;
render();
}
}
if(action === 'update-status') {
state.currentAction.statut[ds.tenant][ds.env] = event.target.value;
}
}
function handleActionFormSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const actionData = state.currentAction;
actionData.sujet = formData.get('sujet');
actionData.type = formData.get('type');
actionData.moment = formData.get('moment');
actionData.url = formData.get('url');
actionData.jira = formData.get('jira');
actionData.remarque = formData.get('remarque');
const index = state.selectedPlan.actions.findIndex(a => a.id === actionData.id);
if (index > -1) {
state.selectedPlan.actions[index] = actionData;
} else {
actionData.id = crypto.randomUUID();
state.selectedPlan.actions.push(actionData);
}
state.isActionModalOpen = false;
state.currentAction = null;
render();
}
// --- TOP-LEVEL ACTION HANDLERS ---
function handleSavePlan() {
const planToSave = state.selectedPlan;
if (planToSave) {
planToSave.lastModified = new Date().toISOString();
console.log("--- SAVING PLAN TO SERVER ---");
console.log(JSON.stringify(planToSave, null, 2));
console.log("--- SAVE COMPLETE ---");
const index = state.deploymentPlans.findIndex(p => p.id === planToSave.id);
if (index > -1) {
state.deploymentPlans[index] = { ...planToSave };
}
state.lastSaveMessage = new Date().toLocaleTimeString();
render();
}
}
function handleCreateNewPlan() {
const newPlan = {
id: crypto.randomUUID(),
name: "Nouveau Plan de Déploiement",
lastModified: new Date().toISOString(),
jiraOverviewUrl: '',
bpmnUrl: '',
actions: [],
qualityChecks: { business: [], technical: [] },
detailedPlan: { acc: { onss: [], onem: [] }, prod: { onss: [], onem: [] } },
};
state.deploymentPlans.push(newPlan);
state.selectedPlan = JSON.parse(JSON.stringify(newPlan));
state.view = 'detail';
render();
}
// --- EVENT BINDING ---
let mainClickListener, mainInputListener, formSubmitListener;
function bindEvents() {
// Use event delegation on the root container for performance
mainClickListener = handleEvent;
document.body.addEventListener('click', mainClickListener);
// For live input updates that don't need a full re-render
mainInputListener = handleEvent;
document.body.addEventListener('input', mainInputListener);
const actionForm = document.getElementById('action-form');
if (actionForm) {
formSubmitListener = handleActionFormSubmit;
actionForm.addEventListener('submit', formSubmitListener);
}
}
function unbindEvents() {
if (mainClickListener) document.body.removeEventListener('click', mainClickListener);
if (mainInputListener) document.body.removeEventListener('input', mainInputListener);
const actionForm = document.getElementById('action-form');
if (actionForm && formSubmitListener) {
actionForm.removeEventListener('submit', formSubmitListener);
}
}
// --- INITIALIZATION ---
function init() {
console.log("Initializing Deployment Planner...");
state.deploymentPlans = getMockData();
render();
}
function getMockData() {
const emptyStatusGrid = createEmptyStatusGrid();
return [{
id: 'plan-1',
name: "Déploiement Q3 2025 - Microservice 'Orion'",
lastModified: "2025-09-12T10:00:00Z",
jiraOverviewUrl: "",
bpmnUrl: "",
actions: [
{ id: 'act-1', sujet: 'Déployer API Gateway v1.2', type: ActionType.COMPONENT, moment: ActionMoment.DURING, statut: { ...emptyStatusGrid, onss: {...emptyStatusGrid.onss, int: ItemStatus.DONE} }, remarque: 'Nécessite un redémarrage du pod', url: 'http://example.com/docs/api-gateway', jira: 'http://jira.com/OR-123' },
{ id: 'act-2', sujet: 'Mettre à jour le secret DB_PASSWORD', type: ActionType.SECRET, moment: ActionMoment.BEFORE, statut: { ...emptyStatusGrid }, remarque: '', url: '', jira: 'http://jira.com/OR-124' }
],
qualityChecks: {
business: [{ id: 'qc-b-1', sujet: 'Valider le flux de commande principal', statut: ItemStatus.TODO }],
technical: [{ id: 'qc-t-1', sujet: 'Tests de performance > 100 req/s', statut: ItemStatus.TODO }]
},
detailedPlan: {
acc: {
onss: [{ id: 'dp-acc-onss-1', title: 'Déploiement ACC ONSS', isFinished: false, actions: [{id: 'dpa-1', actionId: 'act-1', responsable: 'John Doe', moment: '2025-09-15T09:00'}] }],
onem: []
},
prod: { onss: [], onem: [] }
}
}];
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>