Files
planer2/frontend/src/App.vue
2026-07-29 15:19:41 +02:00

280 lines
9.4 KiB
Vue

<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { fetchProjekte } from './api.js'
import SeitenLeiste from './components/SeitenLeiste.vue'
import FeatureListe from './components/FeatureListe.vue'
import KontextMenue from './components/KontextMenue.vue'
import PruefPanel from './components/PruefPanel.vue'
import ChatPanel from './components/ChatPanel.vue'
import {
laeuft as planungLaeuft,
sicht as planungSicht,
anzahl as planungAnzahl,
meldung as planungMeldung,
lade as ladePlanung,
starte as startePlanung,
umsetzen as setzePlanungUm,
brichAb as brichPlanungAb,
} from './composables/planung.js'
import {
offen as pruefungOffen,
laeuft as pruefungLaeuft,
prozent as pruefungProzent,
starte as startePruefung,
schliesse as schliessePruefung,
} from './composables/pruefung.js'
const projekte = ref([])
const aktivesProjekt = ref(null)
const meldung = ref('')
// Gespräch und Prüfung teilen sich die rechte Spalte — beide gleichzeitig
// wären zwei Panels für dieselbe Fläche.
const chatOffen = ref(false)
// In der Übersicht schaut man nur nach. Wer etwas ändern will, wechselt in die Planung.
const inPlanung = computed(() => planungLaeuft.value && planungSicht.value === 'planung')
const nurAnsehen = computed(() => planungLaeuft.value && !inPlanung.value)
// Umlegen heißt: die andere Seite zeigen. Gibt es noch keine Planung, entsteht sie hier.
function schalte() {
if (!planungLaeuft.value) return startePlanung()
planungSicht.value = inPlanung.value ? 'uebersicht' : 'planung'
}
const stand = ref(0)
function oeffneChat() {
if (pruefungOffen.value) schliessePruefung()
chatOffen.value = !chatOffen.value
}
// Drei Modi im Kreis; „auto" folgt der Systemeinstellung
const MODI = ['auto', 'hell', 'dunkel']
const ICONS = { auto: 'A', hell: '☀', dunkel: '☾' }
const TITEL = { auto: 'Systemeinstellung', hell: 'Hellmodus', dunkel: 'Dunkelmodus' }
const systemDunkel = window.matchMedia('(prefers-color-scheme: dark)')
const gemerkterModus = localStorage.getItem('darkModus')
const modus = ref(MODI.includes(gemerkterModus) ? gemerkterModus : 'auto')
async function ladeProjekte() {
meldung.value = ''
try {
projekte.value = await fetchProjekte()
} catch (e) {
meldung.value = e.message
return
}
// Auswahl verwerfen, wenn das Projekt (z. B. nach Löschen) weg ist
if (aktivesProjekt.value && !projekte.value.some(p => p.name === aktivesProjekt.value)) {
aktivesProjekt.value = null
}
}
function umbenannt(alt, neu) {
if (aktivesProjekt.value === alt) aktivesProjekt.value = neu
ladeProjekte()
}
// Prüfung gehört zum Projekt — beim Wechsel wird sie verworfen
watch(aktivesProjekt, () => { if (pruefungOffen.value) schliessePruefung() })
watch(aktivesProjekt, n => ladePlanung(n))
// Planen heißt, die Karten mit der KI zu entwickeln. Das Gespräch ist dabei kein
// Zusatz, sondern das Werkzeug — wer die Planung sieht, sieht es auch. Am Reiter
// und nicht am Startknopf, damit es auch nach einem Neustart wieder dasteht.
watch(planungSicht, s => {
if (s !== 'planung') {
chatOffen.value = false // in der Übersicht gibt es nichts zu besprechen
return
}
if (pruefungOffen.value) schliessePruefung()
chatOffen.value = true
})
// Umsetzen und Abbrechen ändern die Karten — die Liste muss neu lesen.
async function planungBeenden(wie) {
const ok = wie === 'umsetzen' ? await setzePlanungUm() : await brichPlanungAb()
if (ok) stand.value++
}
watch(pruefungOffen, o => { if (o) chatOffen.value = false })
// Zuletzt aktives Projekt überlebt das Neuladen
watch(aktivesProjekt, name => {
if (name) localStorage.setItem('aktivesProjekt', name)
else localStorage.removeItem('aktivesProjekt')
})
function wendeAn() {
const dunkel = modus.value === 'auto' ? systemDunkel.matches : modus.value === 'dunkel'
document.documentElement.classList.toggle('dark', dunkel)
}
function wechsleDarkMode() {
modus.value = MODI[(MODI.indexOf(modus.value) + 1) % MODI.length]
localStorage.setItem('darkModus', modus.value)
wendeAn()
}
// Systemwechsel wirkt sofort, solange „auto" aktiv ist
systemDunkel.addEventListener('change', () => { if (modus.value === 'auto') wendeAn() })
onMounted(async () => {
await ladeProjekte()
const gemerkt = localStorage.getItem('aktivesProjekt')
if (gemerkt && projekte.value.some(p => p.name === gemerkt)) {
aktivesProjekt.value = gemerkt
}
// Eine Planung liegt auf der Platte. Nach einem Neustart steht sie wieder da.
await ladePlanung(aktivesProjekt.value)
})
</script>
<template>
<div class="raster" :class="{ pruefung: pruefungOffen || chatOffen }">
<SeitenLeiste
:projekte="projekte"
:aktiv="aktivesProjekt"
@auswaehlen="aktivesProjekt = $event"
@geaendert="ladeProjekte"
@umbenannt="umbenannt"
/>
<main>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<p v-if="planungMeldung" class="meldung">{{ planungMeldung }}</p>
<FeatureListe
v-if="aktivesProjekt"
:projekt="aktivesProjekt"
:projekte="projekte"
:stand="stand"
>
<template #werkzeuge>
<!-- Ein Schalter, zwei Stellungen. Der Text nennt, was gerade zu sehen ist.
Er steht immer da: „wo bin ich" ist auch dann eine Frage, wenn noch
nichts geplant wird. Wer dann umlegt, beginnt eine Planung. -->
<button
class="schalter" :class="{ planung: inPlanung }"
:title="inPlanung ? 'Wechseln zu Übersicht'
: (planungLaeuft ? 'Wechseln zu Planung' : 'Planung beginnen')"
@click="schalte"
>
⇄ {{ inPlanung ? 'Planung' : 'Übersicht' }}
<span v-if="inPlanung && planungAnzahl" class="zahl">{{ planungAnzahl }}</span>
</button>
<!-- Jedes Werkzeug hier ändert Karten. Die Übersicht sagt nur, was das Projekt
heute kann — dort wird nichts entschieden und nichts angefasst. -->
<template v-if="!nurAnsehen">
<button
class="icon-knopf"
:class="{ an: chatOffen }"
title="Karten im Gespräch entwickeln"
@click="oeffneChat"
>✎</button>
<button class="icon-knopf" title="Stil prüfen" @click="startePruefung(aktivesProjekt, 'stil')">⌾</button>
<template v-if="planungLaeuft">
<button class="icon-knopf" title="Planung umsetzen" @click="planungBeenden('umsetzen')">▶</button>
<button class="icon-knopf rot" title="Planung verwerfen" @click="planungBeenden('abbrechen')">✕</button>
</template>
<span v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
</template>
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
{{ ICONS[modus] }}
</button>
</template>
</FeatureListe>
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
</main>
<PruefPanel v-if="pruefungOffen" />
<ChatPanel
v-else-if="chatOffen && aktivesProjekt"
:projekt="aktivesProjekt"
@schliessen="chatOffen = false"
/>
<KontextMenue />
</div>
</template>
<style scoped>
/* App-Shell: feste Höhe, drei eigene Scrollbereiche. Vorher wanderten die
Leisten beim Scrollen mit nach oben weg. */
.raster {
display: grid;
grid-template-columns: 15rem 1fr;
height: 100vh;
overflow: hidden;
}
.raster.pruefung { grid-template-columns: 15rem 1fr 20rem; }
main {
padding: 1.2rem 1.5rem;
min-width: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
/* Mittlere Fenster: drei Spalten passen nicht mehr. Bei 760px blieben für die
Karten 200px übrig, und die Kartenköpfe liefen über. Das Panel legt sich dann
unter den Arbeitsbereich, die Projektliste bleibt daneben. */
@media (min-width: 46.01rem) and (max-width: 64rem) {
.raster.pruefung {
grid-template-columns: 15rem 1fr;
grid-template-rows: 1fr 45vh;
/* Benannte Bereiche statt Auto-Platzierung: mit `grid-row` allein landete
der Arbeitsbereich unter der Leiste statt daneben. */
grid-template-areas:
"leiste inhalt"
"leiste panel";
}
.raster.pruefung > aside { grid-area: leiste; }
.raster.pruefung > main { grid-area: inhalt; }
.raster.pruefung > .panel,
.raster.pruefung > .chat { grid-area: panel; }
}
/* Schmale Fenster: die Projektliste legt sich als Leiste nach oben */
@media (max-width: 46rem) {
.raster, .raster.pruefung {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.raster.pruefung { grid-template-rows: auto 1fr auto; }
main { padding: 0.9rem 1rem; }
}
.leer {
color: var(--dezent);
margin-top: 3rem;
text-align: center;
}
.fortschritt {
font-size: 0.7rem;
color: var(--dezent);
margin-left: 0.2rem;
}
.icon-knopf.an { color: var(--akzent); background: var(--code-bg); }
/* Ein Schalter statt zweier Reiter. Beschriftet mit dem, was zu sehen ist —
das Pfeilpaar sagt, dass ein Klick die andere Seite zeigt. */
.schalter {
border: 1px solid var(--rand);
background: none;
color: var(--dezent);
border-radius: 0.4rem;
padding: 0.2rem 0.55rem;
margin-right: 0.3rem;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
white-space: nowrap;
}
.schalter.planung {
color: var(--akzent-text);
background: var(--akzent);
border-color: var(--akzent);
}
.schalter .zahl { margin-left: 0.3rem; font-weight: 700; }
</style>