313 lines
10 KiB
Vue
313 lines
10 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, watch } from 'vue';
|
|
import ManageTopicsModal from '@/components/gantt/manageTopic.vue';
|
|
import ManageProjectsModal from '@/components/gantt/manageProject.vue';
|
|
import ManageConfigModal from '@/components/gantt/manageConfig.vue';
|
|
|
|
const isTopicsModalOpen = ref(false);
|
|
const isProjectsModalOpen = ref(false);
|
|
const isConfigModalOpen = ref(false);
|
|
const activeProjectForEdit = ref(null);
|
|
|
|
const props = defineProps({
|
|
data: {
|
|
type: Object,
|
|
default: () => ({ status: [], phase: [], topics: [] })
|
|
}
|
|
});
|
|
|
|
const roadmapData = ref({ status: [], phase: [], topics: [] });
|
|
|
|
onMounted(() => {
|
|
const saved = localStorage.getItem('mvp_roadmap_data');
|
|
if (saved) {
|
|
try {
|
|
roadmapData.value = JSON.parse(saved);
|
|
} catch (e) {
|
|
console.error("Fehler beim Parsen der gespeicherten Daten", e);
|
|
roadmapData.value = JSON.parse(JSON.stringify(props.data));
|
|
}
|
|
} else {
|
|
roadmapData.value = JSON.parse(JSON.stringify(props.data));
|
|
}
|
|
});
|
|
|
|
watch(roadmapData, (newVal) => {
|
|
localStorage.setItem('mvp_roadmap_data', JSON.stringify(newVal));
|
|
}, { deep: true });
|
|
|
|
const fileInputRef = ref(null);
|
|
|
|
const triggerImportClick = () => {
|
|
fileInputRef.value?.click();
|
|
};
|
|
|
|
const handleImport = (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const parsedData = JSON.parse(e.target.result);
|
|
if (parsedData.status && parsedData.phase && parsedData.topics) {
|
|
roadmapData.value = parsedData;
|
|
alert("Daten erfolgreich importiert!");
|
|
} else {
|
|
alert("Ungültiges Format. Das JSON muss 'status', 'phase' and 'topics' enthalten.");
|
|
}
|
|
} catch (err) {
|
|
alert("Fehler beim Lesen der Datei. Ist es ein gültiges JSON?");
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
event.target.value = "";
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData.value, null, 2));
|
|
const downloadAnchor = document.createElement('a');
|
|
downloadAnchor.setAttribute("href", dataStr);
|
|
downloadAnchor.setAttribute("download", "roadmap_export.json");
|
|
document.body.appendChild(downloadAnchor);
|
|
downloadAnchor.click();
|
|
downloadAnchor.remove();
|
|
};
|
|
|
|
const addNewTopic = () => {
|
|
roadmapData.value.topics.push({ id: Date.now(), name: 'Neues Thema', project: [] });
|
|
};
|
|
|
|
const addNewProject = (topicId) => {
|
|
const topic = roadmapData.value.topics.find(t => t.id === topicId);
|
|
if (topic) {
|
|
topic.project.push({ id: Date.now(), name: 'Neues Projekt', isRepeatable: false, phase: [] });
|
|
}
|
|
};
|
|
|
|
const handleOpenSettingsFromTopics = (project) => {
|
|
isTopicsModalOpen.value = false;
|
|
activeProjectForEdit.value = project;
|
|
isProjectsModalOpen.value = true;
|
|
};
|
|
|
|
const handleSaveFromModal = (updatedData) => {
|
|
roadmapData.value = updatedData;
|
|
isTopicsModalOpen.value = false;
|
|
isProjectsModalOpen.value = false;
|
|
isConfigModalOpen.value = false;
|
|
};
|
|
|
|
const allDates = computed(() => {
|
|
const dates = [];
|
|
roadmapData.value.topics.forEach(topic => {
|
|
topic.project.forEach(proj => {
|
|
proj.phase?.forEach(p => {
|
|
if (p.start) dates.push(new Date(p.start));
|
|
if (p.end) dates.push(new Date(p.end));
|
|
});
|
|
});
|
|
});
|
|
return dates;
|
|
});
|
|
|
|
const minYearFound = computed(() => allDates.value.length ? Math.min(...allDates.value.map(d => d.getFullYear())) : 2026);
|
|
const maxYearFound = computed(() => allDates.value.length ? Math.max(...allDates.value.map(d => d.getFullYear())) : 2027);
|
|
|
|
const selectedStartYear = ref(2026);
|
|
const selectedEndYear = ref(2027);
|
|
|
|
watch([minYearFound, maxYearFound], ([minY, maxY]) => {
|
|
if (allDates.value.length) {
|
|
selectedStartYear.value = minY;
|
|
selectedEndYear.value = maxY;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
const viewStart = computed(() => new Date(`${selectedStartYear.value}-01-01`));
|
|
const viewEnd = computed(() => new Date(`${selectedEndYear.value}-12-31`));
|
|
|
|
const gridColumns = computed(() => {
|
|
const cols = [];
|
|
let current = new Date(viewStart.value);
|
|
while (current <= viewEnd.value) {
|
|
cols.push(new Date(current));
|
|
current.setMonth(current.getMonth() + 1);
|
|
}
|
|
return cols;
|
|
});
|
|
|
|
const yearSpans = computed(() => {
|
|
const spans = {};
|
|
gridColumns.value.forEach(date => {
|
|
const y = date.getFullYear();
|
|
spans[y] = (spans[y] || 0) + 1;
|
|
});
|
|
return Object.entries(spans).map(([year, count]) => ({ year, count }));
|
|
});
|
|
|
|
const availableYears = computed(() => {
|
|
const years = [];
|
|
const start = Math.min(selectedStartYear.value, minYearFound.value);
|
|
const end = Math.max(selectedEndYear.value, maxYearFound.value);
|
|
for (let y = start - 1; y <= end + 2; y++) {
|
|
years.push(y);
|
|
}
|
|
return years;
|
|
});
|
|
|
|
const getPosition = (dateString) => {
|
|
const date = new Date(dateString);
|
|
const totalDuration = viewEnd.value - viewStart.value;
|
|
const elapsed = date - viewStart.value;
|
|
return (elapsed / totalDuration) * 100;
|
|
};
|
|
|
|
const getWidth = (startStr, endStr) => {
|
|
const start = new Date(startStr);
|
|
const end = new Date(endStr);
|
|
const totalDuration = viewEnd.value - viewStart.value;
|
|
const phaseDuration = end - start;
|
|
return (phaseDuration / totalDuration) * 100;
|
|
};
|
|
|
|
const getPhaseColor = (phaseId) => {
|
|
const phase = roadmapData.value.phase.find(p => p.id === phaseId);
|
|
return phase ? phase.color : '#3b82f6';
|
|
};
|
|
|
|
const getRepeatMarkers = (project) => {
|
|
if (!project.isRepeatable || !project.repeat?.times) return [];
|
|
const markers = [];
|
|
for (let y = selectedStartYear.value; y <= selectedEndYear.value; y++) {
|
|
project.repeat.times.forEach(monthNum => {
|
|
const markerDate = new Date(`${y}-${String(monthNum).padStart(2, '0')}-15`);
|
|
if (markerDate >= viewStart.value && markerDate <= viewEnd.value) {
|
|
markers.push({ id: `${y}-${monthNum}`, position: getPosition(markerDate) });
|
|
}
|
|
});
|
|
}
|
|
return markers;
|
|
};
|
|
|
|
const isDecember = (date) => date.getMonth() === 11;
|
|
</script>
|
|
|
|
<template>
|
|
<ManageTopicsModal
|
|
:isOpen="isTopicsModalOpen"
|
|
:data="roadmapData"
|
|
@close="isTopicsModalOpen = false"
|
|
@open-project-settings="handleOpenSettingsFromTopics"
|
|
@add-topic="addNewTopic"
|
|
@add-project="addNewProject"
|
|
@save="handleSaveFromModal"
|
|
/>
|
|
|
|
<ManageProjectsModal
|
|
:isOpen="isProjectsModalOpen"
|
|
:data="roadmapData"
|
|
:highlightProjectId="activeProjectForEdit?.id"
|
|
@close="isProjectsModalOpen = false"
|
|
@save="handleSaveFromModal"
|
|
/>
|
|
|
|
<ManageConfigModal
|
|
:isOpen="isConfigModalOpen"
|
|
:data="roadmapData"
|
|
@close="isConfigModalOpen = false"
|
|
@save="handleSaveFromModal"
|
|
/>
|
|
|
|
<div class="app-wrapper">
|
|
<div>
|
|
<div class="logo-area">
|
|
<h1></h1>
|
|
<div class="range-picker">
|
|
<select v-model="selectedStartYear">
|
|
<option v-for="y in availableYears" :key="y" :value="y">{{ y }}</option>
|
|
</select>
|
|
<span> bis </span>
|
|
<select v-model="selectedEndYear">
|
|
<option v-for="y in availableYears" :key="y" :value="y">{{ y }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<input type="file" ref="fileInputRef" style="display: none" accept=".json" @change="handleImport" />
|
|
|
|
<button class="btn btn-io" @click="triggerImportClick">📥 Import JSON</button>
|
|
<button class="btn btn-io" @click="handleExport">📤 Export JSON</button>
|
|
<span class="divider-space">|</span>
|
|
<button class="btn" @click="isTopicsModalOpen = true">Manage Topics</button>
|
|
<button class="btn" @click="isProjectsModalOpen = true">Manage Projects</button>
|
|
<button class="btn btn-primary" @click="isConfigModalOpen = true">Manage Config</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="timeline-card">
|
|
<div class="timeline-header-row">
|
|
<div class="sidebar-label headline">Projekte / Themen</div>
|
|
<div class="time-scale">
|
|
<div class="years-grid" :style="{ gridTemplateColumns: `repeat(${gridColumns.length}, 1fr)` }">
|
|
<div v-for="y in yearSpans" :key="y.year" :style="{ gridColumn: `span ${y.count}` }" class="year-block">
|
|
{{ y.year }}
|
|
</div>
|
|
</div>
|
|
<div class="months-grid" :style="{ gridTemplateColumns: `repeat(${gridColumns.length}, 1fr)` }">
|
|
<div v-for="date in gridColumns" :key="date" class="month-label" :class="{ 'year-end': isDecember(date) }">
|
|
{{ date.toLocaleString('de-DE', { month: 'short' }).toUpperCase() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="timeline-content">
|
|
<div v-if="roadmapData.topics.length === 0" class="no-data-notice">
|
|
Keine Daten vorhanden. Klicke auf "Manage Topics" oder nutze "Import JSON" um zu starten!
|
|
</div>
|
|
|
|
<div v-for="topic in roadmapData.topics" :key="topic.id" class="topic-section">
|
|
<div class="topic-header">{{ topic.name }}</div>
|
|
|
|
<div v-for="project in topic.project" :key="project.id" class="project-row">
|
|
<div class="project-info">
|
|
<span class="p-name">{{ project.name }}</span>
|
|
<span v-if="project.isRepeatable" class="badge">Repeatable</span>
|
|
</div>
|
|
|
|
<div class="project-timeline-track">
|
|
<div class="grid-overlay" :style="{ gridTemplateColumns: `repeat(${gridColumns.length}, 1fr)` }">
|
|
<div v-for="date in gridColumns" :key="date" class="grid-line" :class="{ 'year-end': isDecember(date) }"></div>
|
|
</div>
|
|
|
|
<template v-if="!project.isRepeatable">
|
|
<div
|
|
v-for="p in project.phase"
|
|
:key="p.id"
|
|
class="phase-bar"
|
|
:style="{
|
|
left: getPosition(p.start) + '%',
|
|
width: getWidth(p.start, p.end) + '%',
|
|
backgroundColor: getPhaseColor(p.phase)
|
|
}"
|
|
>
|
|
<div v-if="p.milestoneAtStart" class="milestone start">◆</div>
|
|
<span class="phase-text">{{ roadmapData.phase.find(ph => ph.id === p.phase)?.name || p.phase }}</span>
|
|
<div v-if="p.milestoneAtEnd" class="milestone end">◆</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div v-for="marker in getRepeatMarkers(project)" :key="marker.id" class="repeat-marker" :style="{ left: marker.position + '%' }">
|
|
<i>R</i>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|