🎉 Initial Commit
This commit is contained in:
commit
12a13f171f
23 changed files with 8462 additions and 0 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
42
README.md
Normal file
42
README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# gantt
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
1
env.d.ts
vendored
Normal file
1
env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
5
gantt_dockerfolder/.dockerignore
Normal file
5
gantt_dockerfolder/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
26
gantt_dockerfolder/docker-compose.yml
Normal file
26
gantt_dockerfolder/docker-compose.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: gantt
|
||||||
|
services:
|
||||||
|
gantt-app:
|
||||||
|
image: nginx:stable-alpine
|
||||||
|
container_name: gantt-container
|
||||||
|
ports:
|
||||||
|
- "80"
|
||||||
|
volumes:
|
||||||
|
- ./dist:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- sure_net
|
||||||
|
- op_xwiki_frontend
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.gantt.rule=Host(`gantt.bybenji.de`)"
|
||||||
|
- "traefik.http.routers.gantt.entrypoints=web"
|
||||||
|
- "traefik.http.services.gantt.loadbalancer.server.port=80"
|
||||||
|
- "traefik.docker.network=op_xwiki_frontend"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sure_net:
|
||||||
|
driver: bridge
|
||||||
|
op_xwiki_frontend:
|
||||||
|
external: true
|
||||||
15
gantt_dockerfolder/nginx.conf
Normal file
15
gantt_dockerfolder/nginx.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Projektvisualisierung.bybenji.de</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7266
package-lock.json
generated
Normal file
7266
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "gantt",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.32"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
21
src/App.vue
Normal file
21
src/App.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import ProjectTimeline from './components/gantt.vue';
|
||||||
|
|
||||||
|
// Startzustand direkt als lokales Objekt definieren (Kein Fetch mehr nötig!)
|
||||||
|
const defaultData = ref({
|
||||||
|
status: [
|
||||||
|
{ id: 'status-1', name: 'In Progress' },
|
||||||
|
{ id: 'status-2', name: 'Done' }
|
||||||
|
],
|
||||||
|
phase: [
|
||||||
|
{ id: 'phase-1', name: 'Konzeption', color: '#4f46e5' },
|
||||||
|
{ id: 'phase-2', name: 'Umsetzung', color: '#16a34a' }
|
||||||
|
],
|
||||||
|
topics: [] // Startet komplett leer
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProjectTimeline :data="defaultData" />
|
||||||
|
</template>
|
||||||
363
src/components/gantt.vue
Normal file
363
src/components/gantt.vue
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import ManageTopicsModal from './manageTopic.vue';
|
||||||
|
import ManageProjectsModal from './manageProject.vue';
|
||||||
|
import ManageConfigModal from './manageConfig.vue';
|
||||||
|
|
||||||
|
// --- MODAL STATUS ---
|
||||||
|
const isTopicsModalOpen = ref(false);
|
||||||
|
const isProjectsModalOpen = ref(false);
|
||||||
|
const isConfigModalOpen = ref(false);
|
||||||
|
const activeProjectForEdit = ref(null);
|
||||||
|
|
||||||
|
// --- PROP & REAKTIVE LOKALE DATEN ---
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ status: [], phase: [], topics: [] })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Das ist unser lokaler Zustand, mit dem wir arbeiten
|
||||||
|
const roadmapData = ref({ status: [], phase: [], topics: [] });
|
||||||
|
|
||||||
|
// Daten-Initialisierung (Proxy-sicher)
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatisches Speichern im LocalStorage bei jeder Änderung
|
||||||
|
watch(roadmapData, (newVal) => {
|
||||||
|
localStorage.setItem('mvp_roadmap_data', JSON.stringify(newVal));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// --- IMPORT & EXPORT LOGIK ---
|
||||||
|
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);
|
||||||
|
// Zurücksetzen, damit dieselbe Datei nochmals gewählt werden kann
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RESTLICHE SCHLEIFEN & GEÄNDERTE FUNKTIONEN (greifen nun auf roadmapData zu) ---
|
||||||
|
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;
|
||||||
|
// Schließe sicherheitshalber alle Modals nach dem Speichern
|
||||||
|
isTopicsModalOpen.value = false;
|
||||||
|
isProjectsModalOpen.value = false;
|
||||||
|
isConfigModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DYNAMISCHE ZEITRAUM-LOGIK ---
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Sync mit gefundenen Daten, falls vorhanden
|
||||||
|
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">
|
||||||
|
<header class="main-header">
|
||||||
|
<div class="logo-area">
|
||||||
|
<h1>Project Roadmap</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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Behalte deine bisherigen Styles bei und füge diese Erweiterungen hinzu: */
|
||||||
|
.app-wrapper { font-family: 'Inter', system-ui, sans-serif; background-color: #fbe7ee; min-height: 100vh; padding: 40px 20px; }
|
||||||
|
.main-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; max-width: 1400px; margin-left: auto; margin-right: auto; }
|
||||||
|
.logo-area { display: flex; align-items: center; gap: 25px; }
|
||||||
|
.logo-area h1 { font-size: 1.8rem; color: #0f2d59; font-weight: 800; margin: 0; }
|
||||||
|
.range-picker { background: white; padding: 6px 18px; border-radius: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); font-weight: 600; }
|
||||||
|
.range-picker select { border: none; font-weight: 700; cursor: pointer; background: transparent; font-size: 0.9rem; }
|
||||||
|
.actions { display: flex; align-items: center; }
|
||||||
|
.btn { padding: 10px 20px; margin-left: 12px; border: 2px solid #222; border-radius: 10px; background: white; font-weight: 700; cursor: pointer; }
|
||||||
|
.btn-primary { background: #0f2d59; color: white; border: none; }
|
||||||
|
.btn-io { background: #e3f2fd; color: #0f2d59; }
|
||||||
|
.divider-space { margin: 0 15px; font-weight: bold; color: #be9ba2; }
|
||||||
|
.timeline-card { background: #f2c9cf; border: 2px solid #333; border-radius: 16px; overflow: hidden; max-width: 1400px; margin: 0 auto; }
|
||||||
|
.timeline-header-row, .project-row { display: grid; grid-template-columns: 300px 1fr; }
|
||||||
|
.sidebar-label, .project-info { padding: 15px 20px; border-right: 2px solid #333; background: #f9f9f9; display: flex; align-items: center; }
|
||||||
|
.sidebar-label.headline { font-weight: 700; font-size: 1.1rem; background: #f3f3f3; border-bottom: 2px solid #333; }
|
||||||
|
.p-name { font-weight: 700; font-size: 1rem; color: #000; }
|
||||||
|
.topic-header { background: #e5cbd0; padding: 10px 20px; font-weight: 800; font-size: 0.9rem; color: #333; border-bottom: 2px solid #333; border-top: 2px solid #333; }
|
||||||
|
.time-scale { display: flex; flex-direction: column; background: #e5cbd0; border-bottom: 2px solid #333;}
|
||||||
|
.years-grid { display: grid; border-bottom: 1px solid #333; text-align: center;}
|
||||||
|
.year-block { font-weight: 800; padding: 6px 0; font-size: 0.95rem; color: #0f2d59; border-right: 2px solid #333; }
|
||||||
|
.year-block:last-child { border-right: none; }
|
||||||
|
.months-grid { display: grid; text-align: center; }
|
||||||
|
.month-label { font-size: 0.75rem; font-weight: 700; padding: 8px 0; color: #555; border-right: 1px dashed #be9ba2; }
|
||||||
|
.month-label.year-end { border-right: 2px solid #333; }
|
||||||
|
.project-timeline-track { position: relative; height: 75px; }
|
||||||
|
.grid-overlay { position: absolute; inset: 0; display: grid; }
|
||||||
|
.grid-line { border-right: 1px dashed rgba(51, 51, 51, 0.15); height: 100%; }
|
||||||
|
.grid-line.year-end { border-right: 2px solid #333; }
|
||||||
|
.phase-bar { position: absolute; top: 22px; height: 32px; border: 2px solid #222; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.85rem; font-weight: 700; z-index: 2; padding: 0 10px; }
|
||||||
|
.milestone { position: absolute; color: #FFD700; font-size: 1.3rem; }
|
||||||
|
.milestone.start { left: -10px; }
|
||||||
|
.milestone.end { right: -10px; }
|
||||||
|
.repeat-marker { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 32px; height: 32px; background: #e91e63; color: white; border: 2px solid white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: 800; font-style: italic; z-index: 3; }
|
||||||
|
.badge { font-size: 0.65rem; background: #ffe8cc; color: #d97706; padding: 3px 8px; border-radius: 12px; margin-left: 10px; font-weight: 700; }
|
||||||
|
.no-data-notice { padding: 40px; text-align: center; font-weight: bold; color: #0f2d59; font-size: 1.1rem; }
|
||||||
|
</style>
|
||||||
156
src/components/manageConfig.vue
Normal file
156
src/components/manageConfig.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: Boolean,
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save']);
|
||||||
|
|
||||||
|
// Lokale Sandbox-Kopie
|
||||||
|
const localData = ref(null);
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
// VORHER: localData.value = structuredClone(props.data);
|
||||||
|
localData.value = JSON.parse(JSON.stringify(props.data));
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const addStatus = () => {
|
||||||
|
if (!localData.value.status) localData.value.status = [];
|
||||||
|
localData.value.status.push({
|
||||||
|
id: `status-${Date.now()}`,
|
||||||
|
name: 'Neuer Status'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStatus = (index) => {
|
||||||
|
localData.value.status.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPhaseType = () => {
|
||||||
|
if (!localData.value.phase) localData.value.phase = [];
|
||||||
|
localData.value.phase.push({
|
||||||
|
id: `phase-${Date.now()}`,
|
||||||
|
name: 'Neue Phase',
|
||||||
|
color: '#3b82f6'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhaseType = (index) => {
|
||||||
|
localData.value.phase.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGenericConfig = () => {
|
||||||
|
const feedback = prompt("Welches Konfigurationselement oder Feature wünschst du dir? (z.B. 'Teams', 'Ressourcen-Planung')");
|
||||||
|
if (!feedback || !feedback.trim()) return;
|
||||||
|
|
||||||
|
if (!localData.value.developerNotes) localData.value.developerNotes = [];
|
||||||
|
|
||||||
|
localData.value.developerNotes.push({
|
||||||
|
id: `note-${Date.now()}`,
|
||||||
|
timestamp: new Date().toLocaleString('de-DE'),
|
||||||
|
requestedFeature: feedback.trim(),
|
||||||
|
status: 'Backlog / Idee'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSave = () => {
|
||||||
|
emit('save', localData.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="modal-backdrop" @click="emit('close')">
|
||||||
|
<div class="modal-container" @click.stop v-if="localData">
|
||||||
|
|
||||||
|
<div class="modal-scroller">
|
||||||
|
<button class="close-top-btn" @click="emit('close')">✕</button>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="section-badge">status</div>
|
||||||
|
<div class="config-items-list">
|
||||||
|
<div v-for="(statusItem, index) in localData.status" :key="statusItem.id" class="config-item-row">
|
||||||
|
<input type="text" v-model="statusItem.name" class="input-style" placeholder="Status Name" />
|
||||||
|
<button class="delete-item-btn" @click="removeStatus(index)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-btn sub-add" @click="addStatus">+ add status</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="section-badge">phase</div>
|
||||||
|
<div class="config-items-list">
|
||||||
|
<div v-for="(phaseItem, index) in localData.phase" :key="phaseItem.id" class="config-item-row">
|
||||||
|
<input type="text" v-model="phaseItem.name" class="input-style flex-grow" placeholder="Phasen Name" />
|
||||||
|
<input type="color" v-model="phaseItem.color" class="color-picker-input" />
|
||||||
|
<button class="delete-item-btn" @click="removePhaseType(index)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-btn sub-add" @click="addPhaseType">+ add phase</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="global-action-area">
|
||||||
|
<button class="add-btn global-add" @click="addGenericConfig">+ Konfigurationselement</button>
|
||||||
|
<div v-if="localData.developerNotes && localData.developerNotes.length > 0" class="dev-log-container">
|
||||||
|
<div class="dev-log-title">📋 Notizen an die Entwicklung:</div>
|
||||||
|
<div v-for="note in localData.developerNotes" :key="note.id" class="dev-log-entry">
|
||||||
|
<span class="dev-time">[{{ note.timestamp }}]</span>
|
||||||
|
<span class="dev-text">{{ note.requestedFeature }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer-sticky">
|
||||||
|
<button class="footer-btn cancel-btn" @click="emit('close')">Abbrechen</button>
|
||||||
|
<button class="footer-btn save-btn" @click="triggerSave">Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.4); display: flex; justify-content: center; align-items: center; z-index: 1100; }
|
||||||
|
.modal-container { background: white; border: 2px solid #222; border-radius: 24px; width: 90%; max-width: 550px; max-height: 80vh; display: flex; flex-direction: column; position: relative; box-shadow: 0 12px 40px rgba(0,0,0,0.15); overflow: hidden; }
|
||||||
|
.modal-scroller { padding: 30px 30px 15px 30px; overflow-y: auto; flex: 1; }
|
||||||
|
.close-top-btn { position: absolute; top: 15px; right: 20px; background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #666; }
|
||||||
|
.modal-content { display: flex; flex-direction: column; gap: 25px; }
|
||||||
|
.config-section { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.section-badge { align-self: flex-start; border: 2px solid #222; border-radius: 12px; padding: 4px 25px; font-weight: 700; background: #fff; text-transform: lowercase; }
|
||||||
|
.config-items-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.config-item-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.input-style { border: 2px solid #222; border-radius: 12px; padding: 8px 15px; font-weight: 600; font-size: 1rem; width: 220px; }
|
||||||
|
.flex-grow { flex: 1; }
|
||||||
|
.color-picker-input { -webkit-appearance: none; appearance: none; width: 40px; height: 38px; background: transparent; border: 2px solid #222; border-radius: 10px; cursor: pointer; padding: 0; }
|
||||||
|
.color-picker-input::-webkit-color-swatch { border: none; border-radius: 8px; }
|
||||||
|
.delete-item-btn { background: none; border: none; color: #888; font-weight: bold; cursor: pointer; padding: 5px 10px; font-size: 1rem; }
|
||||||
|
.delete-item-btn:hover { color: #cc0000; }
|
||||||
|
.section-divider { border: none; border-top: 2px dashed #eee; }
|
||||||
|
.add-btn { border: 2px solid #222; background: white; font-weight: 700; cursor: pointer; transition: background-color 0.15s; border-radius: 12px; padding: 6px 20px; }
|
||||||
|
.add-btn:hover { background-color: #f5f5f5; }
|
||||||
|
.global-action-area { border-top: 2px solid #222; padding-top: 20px; }
|
||||||
|
|
||||||
|
.modal-footer-sticky { border-top: 2px solid #222; background: #fdfdfd; padding: 15px 30px; display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
.footer-btn { border: 2px solid #222; border-radius: 12px; padding: 10px 25px; font-weight: 700; font-size: 1rem; cursor: pointer; }
|
||||||
|
.cancel-btn { background: white; color: #333; }
|
||||||
|
.cancel-btn:hover { background: #eee; }
|
||||||
|
.save-btn { background: #222; color: white; }
|
||||||
|
.save-btn:hover { background: #444; }
|
||||||
|
|
||||||
|
.dev-log-container { background: #f8fafc; border: 2px dashed #64748b; border-radius: 12px; padding: 12px; margin-top: 15px; }
|
||||||
|
.dev-log-title { font-weight: 700; color: #475569; font-size: 0.85rem; margin-bottom: 5px; }
|
||||||
|
.dev-log-entry { display: flex; gap: 6px; font-family: monospace; font-size: 0.8rem; }
|
||||||
|
.dev-time { color: #94a3b8; }
|
||||||
|
.dev-text { color: #334155; font-weight: 600; }
|
||||||
|
</style>
|
||||||
222
src/components/manageProject.vue
Normal file
222
src/components/manageProject.vue
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: Boolean,
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
highlightProjectId: [String, Number]
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save']);
|
||||||
|
|
||||||
|
const localData = ref(null);
|
||||||
|
const monthLabels = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'];
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
localData.value = JSON.parse(JSON.stringify(props.data));
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Hilfsfunktion: Holt die globale Farbe der ausgewählten Phase für die Live-Vorschau
|
||||||
|
const getPhaseColor = (phaseId) => {
|
||||||
|
const found = localData.value?.phase?.find(p => p.id === phaseId);
|
||||||
|
return found ? found.color : '#ccc';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMonth = (project, monthIdx) => {
|
||||||
|
if (!project.repeat) project.repeat = { times: [] };
|
||||||
|
const index = project.repeat.times.indexOf(monthIdx);
|
||||||
|
if (index > -1) {
|
||||||
|
project.repeat.times.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
project.repeat.times.push(monthIdx);
|
||||||
|
project.repeat.times.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRepeatableToggle = (project) => {
|
||||||
|
if (project.isRepeatable) {
|
||||||
|
project.phase = [];
|
||||||
|
} else {
|
||||||
|
if (project.repeat) project.repeat.times = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPhase = (project) => {
|
||||||
|
if (!project.phase) project.phase = [];
|
||||||
|
project.phase.push({
|
||||||
|
id: `phase-segment-${Date.now()}`,
|
||||||
|
phase: localData.value.phase[0]?.id || '',
|
||||||
|
status: localData.value.status[0]?.id || '',
|
||||||
|
start: '',
|
||||||
|
end: '',
|
||||||
|
milestoneAtStart: false,
|
||||||
|
milestoneAtEnd: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProjectToTopic = (topic) => {
|
||||||
|
topic.project.push({
|
||||||
|
id: `proj-${Date.now()}`,
|
||||||
|
name: 'Neues Projekt',
|
||||||
|
isRepeatable: false,
|
||||||
|
phase: []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSave = () => {
|
||||||
|
emit('save', localData.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="modal-backdrop" @click="emit('close')">
|
||||||
|
<div class="modal-container" @click.stop v-if="localData">
|
||||||
|
|
||||||
|
<!-- SCROLLBARER INHALT -->
|
||||||
|
<div class="modal-scroller">
|
||||||
|
<button class="close-top-btn" @click="emit('close')">✕</button>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<div v-for="topic in localData.topics" :key="topic.id" class="topic-group-wrapper">
|
||||||
|
|
||||||
|
<div class="topic-label">{{ topic.name }}</div>
|
||||||
|
|
||||||
|
<div class="projects-in-topic">
|
||||||
|
<div
|
||||||
|
v-for="project in topic.project"
|
||||||
|
:key="project.id"
|
||||||
|
class="project-config-card"
|
||||||
|
:class="{ 'focused-highlight': project.id === highlightProjectId }"
|
||||||
|
>
|
||||||
|
<div class="config-row main-info">
|
||||||
|
<input type="text" v-model="project.name" class="input-project-name" />
|
||||||
|
|
||||||
|
<label class="repeatable-badge" :class="{ active: project.isRepeatable }">
|
||||||
|
<input type="checkbox" v-model="project.isRepeatable" @change="handleRepeatableToggle(project)" />
|
||||||
|
Repeatable
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Repeatable Ansicht -->
|
||||||
|
<div v-if="project.isRepeatable" class="repeat-section">
|
||||||
|
<div class="months-circle-container">
|
||||||
|
<button
|
||||||
|
v-for="(month, idx) in monthLabels" :key="idx"
|
||||||
|
class="month-circle"
|
||||||
|
:class="{ selected: project.repeat?.times?.includes(idx + 1) }"
|
||||||
|
@click="toggleMonth(project, idx + 1)"
|
||||||
|
>{{ month }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="info-text">Wiederkehrendes Projekt (Phasen deaktiviert)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phasen Ansicht -->
|
||||||
|
<div v-else class="phases-management-area">
|
||||||
|
<div v-for="(p, pIdx) in project.phase" :key="p.id || pIdx" class="phase-config-card-inner">
|
||||||
|
|
||||||
|
<div class="phase-row-top">
|
||||||
|
<!-- Status Dropdown -->
|
||||||
|
<select v-model="p.status" class="select-dropdown">
|
||||||
|
<option v-for="s in localData.status" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Phasen Typ Dropdown -->
|
||||||
|
<select v-model="p.phase" class="select-dropdown flex-grow">
|
||||||
|
<option v-for="ph in localData.phase" :key="ph.id" :value="ph.id">{{ ph.name || ph.id }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Dynamischer Farbpunkt passend zur ausgewählten Phase -->
|
||||||
|
<span class="phase-color-preview-dot" :style="{ backgroundColor: getPhaseColor(p.phase) }"></span>
|
||||||
|
|
||||||
|
<!-- Zeitspanne -->
|
||||||
|
<div class="date-group">
|
||||||
|
<input type="date" v-model="p.start" class="date-input" />
|
||||||
|
<span>bis</span>
|
||||||
|
<input type="date" v-model="p.end" class="date-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="phase-row-bottom">
|
||||||
|
<label class="checkbox-pill" :class="{ checked: p.milestoneAtStart }">
|
||||||
|
<input type="checkbox" v-model="p.milestoneAtStart" /> milestone start
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-pill" :class="{ checked: p.milestoneAtEnd }">
|
||||||
|
<input type="checkbox" v-model="p.milestoneAtEnd" /> milestone end
|
||||||
|
</label>
|
||||||
|
<button class="remove-phase-btn" @click="project.phase.splice(pIdx, 1)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-btn phase-add" @click="addPhase(project)">+ add phase</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-btn project-add-inline" @click="addProjectToTopic(topic)">
|
||||||
|
+ add Project to {{ topic.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FIXIERTER FOOTER -->
|
||||||
|
<div class="modal-footer-sticky">
|
||||||
|
<button class="footer-btn cancel-btn" @click="emit('close')">Abbrechen</button>
|
||||||
|
<button class="footer-btn save-btn" @click="triggerSave">Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; justify-content: center; align-items: center; z-index: 1080; }
|
||||||
|
.modal-container { background: white; border: 2px solid #222; border-radius: 24px; width: 95%; max-width: 850px; max-height: 80vh; display: flex; flex-direction: column; position: relative; box-shadow: 0 12px 40px rgba(0,0,0,0.15); overflow: hidden; }
|
||||||
|
.modal-scroller { padding: 30px 30px 15px 30px; overflow-y: auto; flex: 1; }
|
||||||
|
.close-top-btn { position: absolute; top: 15px; right: 20px; border: none; background: none; cursor: pointer; font-size: 1.2rem; }
|
||||||
|
.modal-content { display: flex; flex-direction: column; gap: 40px; }
|
||||||
|
.topic-group-wrapper { display: flex; flex-direction: column; gap: 15px; border-left: 4px solid #f2c9cf; padding-left: 20px; }
|
||||||
|
.topic-label { align-self: flex-start; background: #f3f3f3; border: 2px solid #222; border-radius: 12px; padding: 5px 20px; font-weight: 800; font-size: 0.85rem; }
|
||||||
|
.projects-in-topic { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.project-config-card { border: 2px solid #222; border-radius: 16px; padding: 20px; background: #fff; }
|
||||||
|
.project-config-card.focused-highlight { border-color: #e91e63; background-color: #fff5f7; }
|
||||||
|
.config-row { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; }
|
||||||
|
.input-project-name { flex: 1; border: 2px solid #222; border-radius: 10px; padding: 8px 12px; font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.repeat-section { background: #f9f9f9; padding: 15px; border-radius: 12px; border: 1px dashed #ccc; }
|
||||||
|
.months-circle-container { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||||
|
.month-circle { width: 34px; height: 34px; border: 2px solid #222; border-radius: 50%; background: white; font-weight: 800; cursor: pointer; }
|
||||||
|
.month-circle.selected { background: #e91e63; color: white; border-color: #e91e63; }
|
||||||
|
.info-text { font-size: 0.75rem; color: #666; font-style: italic; margin: 0; }
|
||||||
|
.repeatable-badge { border: 2px solid #222; border-radius: 10px; padding: 8px 15px; font-weight: 700; cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.repeatable-badge.active { background: #e3f2fd; }
|
||||||
|
.phases-management-area { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.phase-config-card-inner { background: #fff; border: 1px solid #ddd; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.phase-row-top { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.flex-grow { flex: 1; min-width: 150px; }
|
||||||
|
|
||||||
|
/* Neuer runder Indikator für die Phasenfarbe im Editor */
|
||||||
|
.phase-color-preview-dot { width: 18px; height: 18px; border-radius: 50%; border: 2px solid #222; flex-shrink: 0; display: inline-block; }
|
||||||
|
|
||||||
|
.date-group { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 0.85rem; }
|
||||||
|
.date-input { border: 2px solid #222; border-radius: 8px; padding: 4px 8px; font-family: inherit; }
|
||||||
|
.phase-row-bottom { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.remove-phase-btn { margin-left: auto; background: none; border: none; color: #cc0000; cursor: pointer; font-weight: bold; }
|
||||||
|
.select-dropdown { border: 2px solid #222; border-radius: 8px; padding: 6px; font-weight: 600; background: white; }
|
||||||
|
.checkbox-pill { border: 2px solid #222; border-radius: 10px; padding: 5px 12px; font-size: 0.8rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.checkbox-pill.checked { background: #fff9c4; }
|
||||||
|
.add-btn { border: 2px solid #222; border-radius: 10px; background: white; font-weight: 700; cursor: pointer; padding: 8px 15px; }
|
||||||
|
.phase-add { align-self: flex-start; margin-top: 5px; }
|
||||||
|
.project-add-inline { align-self: flex-start; background: #fafafa; }
|
||||||
|
|
||||||
|
.modal-footer-sticky { border-top: 2px solid #222; background: #fdfdfd; padding: 15px 30px; display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
.footer-btn { border: 2px solid #222; border-radius: 12px; padding: 10px 25px; font-weight: 700; font-size: 1rem; cursor: pointer; }
|
||||||
|
.cancel-btn { background: white; color: #333; }
|
||||||
|
.cancel-btn:hover { background: #eee; }
|
||||||
|
.save-btn { background: #222; color: white; }
|
||||||
|
.save-btn:hover { background: #444; }
|
||||||
|
</style>
|
||||||
154
src/components/manageTopic.vue
Normal file
154
src/components/manageTopic.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: Boolean,
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save', 'open-project-settings']);
|
||||||
|
|
||||||
|
const localData = ref(null);
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
// VORHER: localData.value = structuredClone(props.data);
|
||||||
|
localData.value = JSON.parse(JSON.stringify(props.data));
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const handleAddTopic = () => {
|
||||||
|
if (!localData.value.topics) localData.value.topics = [];
|
||||||
|
localData.value.topics.push({
|
||||||
|
id: `topic-${Date.now()}`,
|
||||||
|
name: 'Neues Thema',
|
||||||
|
project: []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTopic = (index) => {
|
||||||
|
if (confirm("Möchtest du dieses Thema samt all seiner Projekte wirklich löschen?")) {
|
||||||
|
localData.value.topics.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProject = (topicId) => {
|
||||||
|
const topic = localData.value.topics.find(t => t.id === topicId);
|
||||||
|
if (topic) {
|
||||||
|
if (!topic.project) topic.project = [];
|
||||||
|
topic.project.push({
|
||||||
|
id: `proj-${Date.now()}`,
|
||||||
|
name: 'Neues Projekt',
|
||||||
|
isRepeatable: false,
|
||||||
|
phase: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveProject = (topic, projIndex) => {
|
||||||
|
topic.project.splice(projIndex, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenSettings = (project) => {
|
||||||
|
emit('open-project-settings', project);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSave = () => {
|
||||||
|
emit('save', localData.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="modal-backdrop" @click="emit('close')">
|
||||||
|
<div class="modal-container" @click.stop v-if="localData">
|
||||||
|
|
||||||
|
<div class="modal-scroller">
|
||||||
|
<button class="close-top-btn" @click="emit('close')">✕</button>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<div v-for="(topic, tIdx) in localData.topics" :key="topic.id" class="topic-group">
|
||||||
|
|
||||||
|
<div class="topic-header-row">
|
||||||
|
<div class="topic-pill-header">
|
||||||
|
<input type="text" v-model="topic.name" class="inline-input bold-text" placeholder="Thema Name" />
|
||||||
|
</div>
|
||||||
|
<button class="delete-icon-btn" @click="handleRemoveTopic(tIdx)" title="Thema löschen">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-list">
|
||||||
|
<div v-for="(project, pIdx) in topic.project" :key="project.id" class="project-pill-row">
|
||||||
|
<input type="text" v-model="project.name" class="inline-input" placeholder="Projekt Name" />
|
||||||
|
|
||||||
|
<div class="project-actions">
|
||||||
|
<button class="delete-icon-btn small-x" @click="handleRemoveProject(topic, pIdx)" title="Projekt löschen">✕</button>
|
||||||
|
<button class="settings-circle-btn" @click="handleOpenSettings(project)" title="Projekteinstellungen öffnen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="gear-icon">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-btn project-add" @click="handleAddProject(topic.id)">
|
||||||
|
+ add Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-btn topic-add" @click="handleAddTopic">
|
||||||
|
+ add Topic
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer-sticky">
|
||||||
|
<button class="footer-btn cancel-btn" @click="emit('close')">Abbrechen</button>
|
||||||
|
<button class="footer-btn save-btn" @click="triggerSave">Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.4); display: flex; justify-content: center; align-items: center; z-index: 1090; }
|
||||||
|
.modal-container { background: white; border: 2px solid #222; border-radius: 24px; width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; position: relative; box-shadow: 0 12px 35px rgba(0,0,0,0.15); overflow: hidden; }
|
||||||
|
.modal-scroller { padding: 30px 30px 15px 30px; overflow-y: auto; flex: 1; }
|
||||||
|
.close-top-btn { position: absolute; top: 15px; right: 20px; background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #666; }
|
||||||
|
.modal-content { display: flex; flex-direction: column; gap: 25px; }
|
||||||
|
.topic-group { display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.topic-header-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
.topic-pill-header { border: 2px solid #222; border-radius: 12px; padding: 4px 15px; font-weight: 700; font-size: 1.1rem; background: #fff; flex-grow: 0; display: flex; align-items: center; }
|
||||||
|
.inline-input { border: none; background: transparent; font-family: inherit; font-size: 1rem; width: 100%; outline: none; font-weight: 600; }
|
||||||
|
.bold-text { font-weight: 700; font-size: 1.05rem; }
|
||||||
|
|
||||||
|
.project-list { display: flex; flex-direction: column; gap: 8px; padding-left: 15px; }
|
||||||
|
.project-pill-row { display: flex; justify-content: space-between; align-items: center; border: 2px solid #222; border-radius: 12px; padding: 6px 12px 6px 20px; background: #fff; }
|
||||||
|
.project-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
.delete-icon-btn { background: none; border: none; color: #888; font-size: 1.1rem; cursor: pointer; font-weight: bold; }
|
||||||
|
.delete-icon-btn:hover { color: #cc0000; }
|
||||||
|
.small-x { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.settings-circle-btn { width: 32px; height: 32px; border: 2px solid #222; border-radius: 50%; background: white; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||||
|
.settings-circle-btn:hover { background-color: #f0f0f0; }
|
||||||
|
.gear-icon { width: 18px; height: 18px; color: #333; }
|
||||||
|
|
||||||
|
.add-btn { border: 2px solid #222; background: white; font-weight: 700; font-size: 1rem; cursor: pointer; text-align: center; }
|
||||||
|
.add-btn:hover { background-color: #f5f5f5; }
|
||||||
|
.project-add { border-radius: 12px; padding: 6px; width: 100%; font-size: 0.9rem; }
|
||||||
|
.topic-add { border-radius: 12px; padding: 8px 25px; align-self: flex-start; margin-top: 5px; }
|
||||||
|
|
||||||
|
.modal-footer-sticky { border-top: 2px solid #222; background: #fdfdfd; padding: 15px 30px; display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
.footer-btn { border: 2px solid #222; border-radius: 12px; padding: 10px 25px; font-weight: 700; font-size: 1rem; cursor: pointer; }
|
||||||
|
.cancel-btn { background: white; color: #333; }
|
||||||
|
.cancel-btn:hover { background: #eee; }
|
||||||
|
.save-btn { background: #222; color: white; }
|
||||||
|
.save-btn:hover { background: #444; }
|
||||||
|
</style>
|
||||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
4
src/main.ts
Normal file
4
src/main.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
18
tsconfig.app.json
Normal file
18
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
tsconfig.node.json
Normal file
27
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
vite.config.ts
Normal file
33
vite.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'inline',
|
||||||
|
manifest: {
|
||||||
|
name: 'Project Roadmap Timeline',
|
||||||
|
short_name: 'Roadmap',
|
||||||
|
description: 'Offline-fähige interaktive Projekt-Roadmap',
|
||||||
|
theme_color: '#fbe7ee',
|
||||||
|
background_color: '#fbe7ee',
|
||||||
|
display: 'standalone',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue