update
BIN
frontend/k1-1024.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/k1-1280.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/k1-1600.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/k1-390.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/k1-640.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/k1-820.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/komfort.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/offen.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -1,10 +1,21 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
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,
|
||||
@@ -17,6 +28,27 @@ 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: '☾' }
|
||||
@@ -47,6 +79,26 @@ function umbenannt(alt, neu) {
|
||||
|
||||
// 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 => {
|
||||
@@ -74,38 +126,75 @@ onMounted(async () => {
|
||||
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 }">
|
||||
<div class="raster" :class="{ pruefung: pruefungOffen || chatOffen }">
|
||||
<SeitenLeiste
|
||||
:projekte="projekte"
|
||||
:aktiv="aktivesProjekt"
|
||||
@auswaehlen="aktivesProjekt = $event"
|
||||
@geaendert="ladeProjekte"
|
||||
@umbenannt="umbenannt"
|
||||
>
|
||||
<span v-if="aktivesProjekt" class="pruefen">
|
||||
<span class="knoepfe">
|
||||
<button class="icon-knopf" title="Stil prüfen" @click="startePruefung(aktivesProjekt, 'stil')">⌾</button>
|
||||
<button class="icon-knopf" title="Features im Code: was steht ohne Code da?" @click="startePruefung(aktivesProjekt, 'feature')">↧</button>
|
||||
<button class="icon-knopf" title="Code in Features: was fehlt auf den Karten?" @click="startePruefung(aktivesProjekt, 'code')">↥</button>
|
||||
</span>
|
||||
<span v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
|
||||
</span>
|
||||
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
|
||||
{{ ICONS[modus] }}
|
||||
</button>
|
||||
</SeitenLeiste>
|
||||
/>
|
||||
|
||||
<main>
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
<FeatureListe v-if="aktivesProjekt" :projekt="aktivesProjekt" />
|
||||
<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>
|
||||
@@ -127,6 +216,25 @@ main {
|
||||
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 {
|
||||
@@ -141,16 +249,31 @@ main {
|
||||
margin-top: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
/* Fortschritt sitzt unter den Prüf-Icons */
|
||||
.pruefen {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.pruefen .knoepfe { display: flex; }
|
||||
.fortschritt {
|
||||
font-size: 0.65rem;
|
||||
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>
|
||||
|
||||
@@ -33,16 +33,37 @@ export const projektUmbenennen = (alt, neu) =>
|
||||
export const projektLoeschen = (name) =>
|
||||
req(`/projekte/${p(name)}`, { method: 'DELETE' })
|
||||
|
||||
export const fetchFeatures = (projekt) => req(`/projekte/${p(projekt)}/features`)
|
||||
export const fetchFeatures = (projekt, sicht = 'planung') =>
|
||||
req(`/projekte/${p(projekt)}/features?sicht=${sicht}`)
|
||||
export const featureAnlegen = (projekt, roh) =>
|
||||
req(`/projekte/${p(projekt)}/features`, { method: 'POST', body: { roh } })
|
||||
export const featureSpeichern = (projekt, id, roh) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } })
|
||||
export const featureLoeschen = (projekt, id) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
|
||||
export const featureVerschieben = (projekt, id, ziel) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}/projekt`, { method: 'PUT', body: { ziel } })
|
||||
|
||||
|
||||
export const pruefungStarten = (projekt, art = 'stil') =>
|
||||
req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST', body: { art } })
|
||||
export const fetchPruefung = (projekt) => req(`/projekte/${p(projekt)}/pruefung`)
|
||||
export const pruefungBeenden = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/pruefung`, { method: 'DELETE' })
|
||||
|
||||
export const fetchPlanung = (projekt) => req(`/projekte/${p(projekt)}/planung`)
|
||||
export const planungStarten = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/planung`, { method: 'POST' })
|
||||
export const planungUmsetzen = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/planung/umsetzen`, { method: 'POST' })
|
||||
export const planungAbbrechen = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/planung`, { method: 'DELETE' })
|
||||
export const planungZuruecksetzen = (projekt, titel) =>
|
||||
req(`/projekte/${p(projekt)}/planung/zuruecksetzen`, { method: 'POST', body: { titel } })
|
||||
|
||||
export const fetchChat = (projekt) => req(`/projekte/${p(projekt)}/chat`)
|
||||
export const chatFragen = (projekt, text) =>
|
||||
req(`/projekte/${p(projekt)}/chat`, { method: 'POST', body: { text } })
|
||||
export const chatLeeren = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/chat`, { method: 'DELETE' })
|
||||
|
||||
|
||||
170
frontend/src/components/ChatPanel.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { fetchChat, chatFragen, chatLeeren } from '../api.js'
|
||||
import { ergaenze } from '../composables/pruefung.js'
|
||||
|
||||
// Das Gespräch ändert die Karten nicht selbst. Es legt Vorschläge daneben — dieselben,
|
||||
// die auch die Prüfung erzeugt. Angenommen wird einzeln, auf der Karte.
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
})
|
||||
const emit = defineEmits(['schliessen'])
|
||||
|
||||
const verlauf = ref([])
|
||||
const entwurf = ref('')
|
||||
const laeuft = ref(false)
|
||||
const meldung = ref('')
|
||||
const liste = ref(null)
|
||||
const feld = ref(null)
|
||||
|
||||
const md = (t) => marked.parse(t)
|
||||
|
||||
async function nachUnten() {
|
||||
await nextTick()
|
||||
if (liste.value) liste.value.scrollTop = liste.value.scrollHeight
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
meldung.value = ''
|
||||
try {
|
||||
verlauf.value = (await fetchChat(props.projekt)).verlauf
|
||||
} catch (e) {
|
||||
verlauf.value = []
|
||||
meldung.value = e.message
|
||||
}
|
||||
nachUnten()
|
||||
}
|
||||
|
||||
async function senden() {
|
||||
const text = entwurf.value.trim()
|
||||
if (!text || laeuft.value) return
|
||||
meldung.value = ''
|
||||
laeuft.value = true
|
||||
// Die eigene Zeile sofort zeigen; auf die Antwort wartet man sonst vor leerem Feld
|
||||
verlauf.value = [...verlauf.value, { rolle: 'person', text }]
|
||||
entwurf.value = ''
|
||||
nachUnten()
|
||||
try {
|
||||
const antwort = await chatFragen(props.projekt, text)
|
||||
verlauf.value = antwort.verlauf
|
||||
if (antwort.probleme.length) ergaenze(antwort.probleme)
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
entwurf.value = text // nichts verlieren, wenn es schiefgeht
|
||||
verlauf.value = verlauf.value.slice(0, -1)
|
||||
}
|
||||
laeuft.value = false
|
||||
nachUnten()
|
||||
feld.value?.focus()
|
||||
}
|
||||
|
||||
async function leeren() {
|
||||
try {
|
||||
await chatLeeren(props.projekt)
|
||||
verlauf.value = []
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.projekt, laden, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="chat">
|
||||
<header>
|
||||
<p class="gruppe">Gespräch</p>
|
||||
<button
|
||||
class="icon-knopf"
|
||||
:disabled="!verlauf.length || laeuft"
|
||||
title="Gespräch verwerfen"
|
||||
@click="leeren"
|
||||
>⌫</button>
|
||||
<button class="icon-knopf" title="Gespräch schließen" @click="emit('schliessen')">✕</button>
|
||||
</header>
|
||||
|
||||
<div ref="liste" class="verlauf">
|
||||
<p v-if="!verlauf.length" class="leer">
|
||||
Beschreibe, was das Projekt können soll. Die Karten kommen als Vorschlag zurück.
|
||||
</p>
|
||||
<div v-for="(e, i) in verlauf" :key="i" class="zeile" :class="e.rolle">
|
||||
<div v-if="e.rolle === 'ki'" class="inhalt" v-html="md(e.text)" />
|
||||
<div v-else class="inhalt">{{ e.text }}</div>
|
||||
</div>
|
||||
<p v-if="laeuft" class="denkt">denkt nach …</p>
|
||||
</div>
|
||||
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
|
||||
<div class="eingabe">
|
||||
<textarea
|
||||
ref="feld"
|
||||
v-model="entwurf"
|
||||
rows="3"
|
||||
placeholder="Was soll das Projekt können?"
|
||||
:disabled="laeuft"
|
||||
@keydown.enter.exact.prevent="senden"
|
||||
/>
|
||||
<button class="primaer" :disabled="!entwurf.trim() || laeuft" @click="senden">
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat {
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--rand);
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
min-height: 0; /* sonst wächst das Panel über die Zeile hinaus */
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.gruppe {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dezent);
|
||||
}
|
||||
/* Eigener Scrollbereich: die Eingabe bleibt immer unten sichtbar */
|
||||
.verlauf {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.leer { color: var(--dezent); font-size: 0.85rem; margin: 0; }
|
||||
.zeile { display: flex; }
|
||||
.zeile.person { justify-content: flex-end; }
|
||||
.inhalt {
|
||||
max-width: 90%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.person .inhalt { background: var(--akzent); color: var(--akzent-text); }
|
||||
.ki .inhalt { background: var(--bg); white-space: normal; }
|
||||
.ki .inhalt :deep(p) { margin: 0.2rem 0; }
|
||||
.ki .inhalt :deep(ul) { margin: 0.2rem 0; padding-left: 1.1rem; }
|
||||
.denkt { color: var(--dezent); font-size: 0.82rem; margin: 0; }
|
||||
.eingabe { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
textarea { width: 100%; resize: vertical; font-size: 0.86rem; }
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { nextTick, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { featureSpeichern } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
import { benenneUm, erledige, vorschlagsArt, vorschlagsZeilen } from '../composables/pruefung.js'
|
||||
import { benenneUm, erledige, verwirf, vorschlagsArt, vorschlagsZeilen } from '../composables/pruefung.js'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
import ProblemHinweis from './ProblemHinweis.vue'
|
||||
|
||||
@@ -12,9 +12,25 @@ const props = defineProps({
|
||||
feature: { type: Object, required: true },
|
||||
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
|
||||
probleme: { type: Array, default: () => [] },
|
||||
verschiebbar: { type: Boolean, default: false }, // es gibt ein anderes Projekt
|
||||
// {art, funktionen: {titel: 'neu'|'weg'|'geaendert'}, kopf} — null außerhalb der Planung
|
||||
planung: { type: Object, default: null },
|
||||
// Übersicht während einer Planung: der Projektstand ist Ansicht, nicht
|
||||
// Arbeitsfläche. Ohne die Sperre landete jede Änderung still in der Kopie.
|
||||
nurLesen: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['aktualisiert', 'entfernen'])
|
||||
const emit = defineEmits(['aktualisiert', 'entfernen', 'verschieben', 'zuruecksetzen'])
|
||||
|
||||
const PLANUNG_TITEL = { neu: 'Neu', weg: 'Wird gestrichen', geaendert: 'Geändert' }
|
||||
|
||||
// Wie eine Funktion in der Planung markiert ist. Ohne Planung: gar nicht.
|
||||
function planungsArt(titel) {
|
||||
return props.planung?.funktionen?.[titel] || null
|
||||
}
|
||||
|
||||
// Die Reihenfolge der Funktionen ist die der Datei. Alphabetisch sortiert stand
|
||||
// „Ziel anpassen" vor „Feature anlegen“ — die Schritte einer Bedienung ließen sich
|
||||
// so nicht in ihrer natürlichen Folge lesen.
|
||||
function kopie(sekt) {
|
||||
return {
|
||||
titel: sekt.titel,
|
||||
@@ -22,8 +38,8 @@ function kopie(sekt) {
|
||||
ziel: sekt.ziel,
|
||||
regeln: sekt.regeln,
|
||||
funktionen: sekt.funktionen
|
||||
.map(f => ({ text: f.text, ziel: f.ziel, regeln: f.regeln, testen: f.testen }))
|
||||
.sort((a, b) => a.text.localeCompare(b.text, 'de', { sensitivity: 'base' })),
|
||||
.map(f => ({ text: f.text, ziel: f.ziel, regeln: f.regeln, testen: f.testen,
|
||||
stand: f.stand || 'gebaut' })),
|
||||
sonstiges: sekt.sonstiges, // nicht angezeigt, aber beim Speichern erhalten
|
||||
}
|
||||
}
|
||||
@@ -54,6 +70,7 @@ function wert(feld, i, j) {
|
||||
}
|
||||
|
||||
async function starte(feld, i, j) {
|
||||
if (props.nurLesen) return
|
||||
if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht
|
||||
const original = wert(feld, i, j)
|
||||
aktiv.value = { feld, i, j, original }
|
||||
@@ -97,22 +114,43 @@ async function fertig() {
|
||||
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', i}
|
||||
|
||||
function funktionsMenue(e, i) {
|
||||
oeffneMenue(e, [
|
||||
{ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) },
|
||||
])
|
||||
if (props.nurLesen) return
|
||||
// Kompakt wird gelesen, nicht angepasst. Sonst ließe sich hier eine Funktion
|
||||
// entfernen, während die Karte sagt: Anpassen nur im Vollmodus.
|
||||
if (!gross.value) return kartenMenue(e)
|
||||
const eintraege = []
|
||||
if (i > 0) eintraege.push({ text: '▲ Nach oben', aktion: () => schiebe(i, -1) })
|
||||
if (i < s.value.funktionen.length - 1) {
|
||||
eintraege.push({ text: '▼ Nach unten', aktion: () => schiebe(i, 1) })
|
||||
}
|
||||
eintraege.push({ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) })
|
||||
oeffneMenue(e, eintraege)
|
||||
}
|
||||
|
||||
async function schiebe(i, richtung) {
|
||||
const liste = s.value.funktionen
|
||||
const j = i + richtung
|
||||
if (j < 0 || j >= liste.length) return
|
||||
;[liste[i], liste[j]] = [liste[j], liste[i]]
|
||||
await speichern()
|
||||
}
|
||||
|
||||
function kartenMenue(e) {
|
||||
if (props.nurLesen) return
|
||||
const eintraege = []
|
||||
if (gross.value) {
|
||||
eintraege.push({ text: '+ Funktion erstellen', aktion: () => (modal.value = { art: 'erstellen' }) })
|
||||
}
|
||||
if (props.verschiebbar) {
|
||||
eintraege.push({ text: '→ Feature verschieben', aktion: () => emit('verschieben') })
|
||||
}
|
||||
eintraege.push({ text: '✕ Feature entfernen', aktion: () => emit('entfernen') })
|
||||
oeffneMenue(e, eintraege)
|
||||
}
|
||||
|
||||
async function funktionErstellen(titel) {
|
||||
s.value.funktionen.push({ text: titel, ziel: '', regeln: '', testen: '' })
|
||||
// Von Hand angelegt heißt: es wird beschrieben, was es gibt. Bestellt wird im Gespräch.
|
||||
s.value.funktionen.push({ text: titel, ziel: '', regeln: '', testen: '', stand: 'gebaut' })
|
||||
modal.value = null
|
||||
await speichern()
|
||||
}
|
||||
@@ -128,6 +166,14 @@ function wechsleArt() {
|
||||
speichern()
|
||||
}
|
||||
|
||||
// Offen heißt: bestellt, aber noch nicht gebaut. Die Vorwärtsprüfung klagt eine
|
||||
// offene Funktion nicht an — sie nimmt sie ab, sobald Code dazu belegt ist.
|
||||
function wechsleStand(i) {
|
||||
const f = s.value.funktionen[i]
|
||||
f.stand = f.stand === 'offen' ? 'gebaut' : 'offen'
|
||||
speichern()
|
||||
}
|
||||
|
||||
const TRENNER = '-'.repeat(40)
|
||||
|
||||
// Aus dem Karten-Zustand wird die Markdown-Datei zusammengesetzt — sie bleibt die Wahrheit
|
||||
@@ -141,6 +187,7 @@ function baueRoh() {
|
||||
const funktionen = s.value.funktionen
|
||||
.map(f => {
|
||||
const bloecke = [`<!-- funktion -->\n${f.text}`]
|
||||
if (f.stand === 'offen') bloecke.push('<!-- stand -->\noffen')
|
||||
if (f.ziel) bloecke.push(`<!-- ziel -->\n${f.ziel}`)
|
||||
if (f.regeln) bloecke.push(`<!-- regeln -->\n${f.regeln}`)
|
||||
if (f.testen) bloecke.push(`<!-- testen -->\n${f.testen}`)
|
||||
@@ -245,7 +292,12 @@ function anwenden(p) {
|
||||
if (p.ganzes_feld ? !neu.length : zeileAendern(titel, p) === null) return false
|
||||
const bekannt = new Map(alt.map(f => [f.text, f]))
|
||||
s.value.funktionen = neu.map(
|
||||
t => bekannt.get(t) || { text: t, ziel: '', regeln: '', testen: '' })
|
||||
t => bekannt.get(t) ||
|
||||
{ text: t, ziel: '', regeln: '', testen: '',
|
||||
stand: p.neu_offen ? 'offen' : 'gebaut' })
|
||||
} else if (p.feld === 'stand') {
|
||||
if (!p.funktion) return false
|
||||
ziel.stand = p.vorschlag
|
||||
} else if (p.feld === 'regeln' || p.feld === 'testen') {
|
||||
if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene
|
||||
if (p.ganzes_feld) {
|
||||
@@ -263,21 +315,29 @@ function anwenden(p) {
|
||||
}
|
||||
|
||||
async function annehmen(p) {
|
||||
// Die ganze Karte fällt weg: hier ist nichts zu speichern, nur zu entfernen.
|
||||
// Der Haken ist die Zustimmung — eine zweite Rückfrage wäre dieselbe Frage.
|
||||
if (p.feld === 'karte') {
|
||||
emit('entfernen') // die Rückfrage steht in der Liste; verschwindet die Karte,
|
||||
return // verschwindet der Befund mit ihr
|
||||
}
|
||||
if (anwenden(p)) erledige(p)
|
||||
await speichern()
|
||||
}
|
||||
|
||||
function ablehnen(p) {
|
||||
erledige(p)
|
||||
verwirf(p)
|
||||
}
|
||||
|
||||
async function alleAnnehmen() {
|
||||
const weg = props.probleme.find(p => p.feld === 'karte')
|
||||
if (weg) return annehmen(weg) // was ganz wegfällt, muss nicht erst gerichtet werden
|
||||
props.probleme.forEach(p => { if (anwenden(p)) erledige(p) })
|
||||
await speichern()
|
||||
}
|
||||
|
||||
function alleAblehnen() {
|
||||
props.probleme.forEach(erledige)
|
||||
props.probleme.forEach(verwirf)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -332,6 +392,12 @@ function alleAblehnen() {
|
||||
>✕</button>
|
||||
</span>
|
||||
</header>
|
||||
<!-- Was die Planung an dieser Karte tut, steht über allem anderen. Der Pfeil
|
||||
nimmt genau diese eine Änderung zurück, der Rest der Planung bleibt. -->
|
||||
<p v-if="planung" class="planungszeile" :class="planung.art">
|
||||
<span class="marke">{{ PLANUNG_TITEL[planung.art] }}</span>
|
||||
<button class="icon-knopf" title="Änderung zurücknehmen" @click.stop="emit('zuruecksetzen')">↺</button>
|
||||
</p>
|
||||
<ProblemHinweis :probleme="problemeAn('titel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<ProblemHinweis :probleme="problemeAn('badge')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
|
||||
@@ -375,8 +441,12 @@ function alleAblehnen() {
|
||||
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
|
||||
</section>
|
||||
|
||||
<section :class="{ leer: !s.funktionen.length && !problemeFeld('funktionen').length }">
|
||||
<section
|
||||
:class="{ leer: !s.funktionen.length && !problemeFeld('funktionen').length
|
||||
&& !problemeFeld('karte').length }"
|
||||
>
|
||||
<p class="gruppe">Funktionen</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('karte')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<ProblemHinweis :probleme="problemeFeld('funktionen')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<ProblemHinweis :probleme="problemeAn('funktionen')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<div class="liste">
|
||||
@@ -384,7 +454,7 @@ function alleAblehnen() {
|
||||
v-for="(f, i) in s.funktionen"
|
||||
:key="i"
|
||||
class="funktion"
|
||||
:class="{ offen: aktiv && aktiv.i === i }"
|
||||
:class="[planungsArt(f.text), { offen: aktiv && aktiv.i === i }]"
|
||||
@contextmenu.prevent.stop="funktionsMenue($event, i)"
|
||||
>
|
||||
<div class="zeile kopf">
|
||||
@@ -403,8 +473,17 @@ function alleAblehnen() {
|
||||
:class="{ fehler: fehlerAn('titel', f.text) }"
|
||||
@click="starte('funktion', i)"
|
||||
>{{ f.text }}</div>
|
||||
<button
|
||||
v-if="gross"
|
||||
class="stand"
|
||||
:class="{ offen: f.stand === 'offen', fehler: fehlerAn('stand', f.text) }"
|
||||
:title="f.stand === 'offen' ? 'Bestellt, noch nicht gebaut' : 'Gebaut'"
|
||||
@click.stop="wechsleStand(i)"
|
||||
>{{ f.stand === 'offen' ? 'offen' : '✓' }}</button>
|
||||
<span v-else-if="f.stand === 'offen'" class="stand offen">offen</span>
|
||||
</div>
|
||||
<ProblemHinweis :probleme="problemeAn('titel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<ProblemHinweis :probleme="problemeAn('stand', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
|
||||
<!-- Ziel, Regeln und Testen nur im Vollmodus -->
|
||||
<div class="details">
|
||||
@@ -502,6 +581,34 @@ function alleAblehnen() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Planung: grün dazugekommen, rot gestrichen, gelb angepasst. Dieselbe Sprache
|
||||
wie die Gegenüberstellung der Prüfung, damit man nicht zweimal lernen muss. */
|
||||
.planungszeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
.planungszeile .marke {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.planungszeile .icon-knopf { margin-left: auto; }
|
||||
.planungszeile.neu .marke { color: var(--gruen); background: var(--gruen-bg); }
|
||||
.planungszeile.weg .marke { color: var(--rot); background: var(--rot-bg); }
|
||||
.planungszeile.geaendert .marke { color: var(--gelb); background: var(--gelb-bg); }
|
||||
|
||||
.funktion.neu { background: var(--gruen-bg); }
|
||||
.funktion.neu > .zeile.kopf { color: var(--gruen); }
|
||||
.funktion.weg { background: var(--rot-bg); }
|
||||
.funktion.weg > .zeile.kopf { color: var(--rot); text-decoration: line-through; }
|
||||
.funktion.geaendert { background: var(--gelb-bg); }
|
||||
.funktion.geaendert > .zeile.kopf { color: var(--gelb); }
|
||||
|
||||
.karte.kern { border-color: var(--akzent); }
|
||||
.karte.optional { border-color: var(--gruen); }
|
||||
/* Prüfung: fehlerhafte Karte und Stelle rot */
|
||||
@@ -509,6 +616,21 @@ function alleAblehnen() {
|
||||
/* Kurz hervorheben, wenn das Panel hierher springt */
|
||||
.karte.gesucht { box-shadow: 0 0 0 3px var(--akzent); }
|
||||
.karte { transition: box-shadow 0.25s; }
|
||||
/* Bestellt, aber noch nicht gebaut — die Vorwärtsprüfung klagt sie nicht an */
|
||||
.stand {
|
||||
flex: none;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: none;
|
||||
border-radius: 99px;
|
||||
background: none;
|
||||
color: var(--dezent);
|
||||
}
|
||||
.stand.offen { color: var(--gelb); background: var(--gelb-bg); }
|
||||
.stand.fehler { color: var(--rot); background: var(--rot-bg); }
|
||||
.karte:not(.gross) .stand { cursor: default; }
|
||||
.fehler { color: var(--rot); }
|
||||
.badge.fehler { color: var(--rot); background: var(--rot-bg); }
|
||||
/* Sammelaktionen der Karte — abgesetzt, damit sie nicht wie die Knöpfe
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
|
||||
import { fetchFeatures, featureAnlegen, featureLoeschen, featureVerschieben } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
import { problemeFuer } from '../composables/pruefung.js'
|
||||
import { probleme, problemeFuer, erledige } from '../composables/pruefung.js'
|
||||
import { laeuft as planungLaeuft, aenderungen, sicht, fuerKarte, setzeZurueck } from '../composables/planung.js'
|
||||
import FeatureKarte from './FeatureKarte.vue'
|
||||
import VisionKarte from './VisionKarte.vue'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
projekte: { type: Array, default: () => [] },
|
||||
// Hochzählen lädt neu — nach einem Rückschritt stimmt sonst nichts mehr
|
||||
stand: { type: Number, default: 0 },
|
||||
})
|
||||
const emit = defineEmits(['geaendert'])
|
||||
|
||||
const features = ref([])
|
||||
const meldung = ref('')
|
||||
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', feature}
|
||||
const suche = ref('')
|
||||
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen'|'verschieben', feature}
|
||||
const neuKey = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
|
||||
|
||||
// Stabiler Key je Karte: die id ändert sich mit dem Titel, ein Wechsel würde
|
||||
@@ -30,7 +36,7 @@ async function laden() {
|
||||
meldung.value = ''
|
||||
neuKey.value = null
|
||||
try {
|
||||
features.value = (await fetchFeatures(props.projekt)).map(mitKey)
|
||||
features.value = (await fetchFeatures(props.projekt, sicht.value)).map(mitKey)
|
||||
} catch (e) {
|
||||
features.value = []
|
||||
meldung.value = e.message
|
||||
@@ -42,12 +48,29 @@ function aktualisiert(altId, f) {
|
||||
if (idx < 0) return
|
||||
features.value[idx] = { ...f, key: features.value[idx].key }
|
||||
sortiere()
|
||||
emit('geaendert')
|
||||
}
|
||||
|
||||
// Die Vision steht über den Features, nicht zwischen ihnen — eigene Zeile, goldener
|
||||
// Rand, und höchstens eine je Projekt.
|
||||
const vision = computed(() => features.value.find(f => f.sektionen.art === 'vision'))
|
||||
const karten = computed(() => features.value.filter(f => f.sektionen.art !== 'vision'))
|
||||
|
||||
// Suche über alles, was auf der Karte steht — Titel, Ziel, Regeln, Funktionen.
|
||||
// Nur die Titel zu durchsuchen fände genau das nicht, was man selten sieht.
|
||||
function passt(f) {
|
||||
const wort = suche.value.trim().toLowerCase()
|
||||
if (!wort) return true
|
||||
const s = f.sektionen
|
||||
const text = [s.titel, s.ziel, s.regeln,
|
||||
...s.funktionen.map(x => `${x.text} ${x.ziel} ${x.regeln} ${x.testen}`)]
|
||||
return text.join(' ').toLowerCase().includes(wort)
|
||||
}
|
||||
|
||||
const karten = computed(() =>
|
||||
features.value.filter(f => f.sektionen.art !== 'vision' && passt(f)))
|
||||
const sichtbareVision = computed(() => (vision.value && passt(vision.value)) ? vision.value : null)
|
||||
const versteckt = computed(() =>
|
||||
features.value.length - karten.value.length - (sichtbareVision.value ? 1 : 0))
|
||||
|
||||
function bereichsMenue(e) {
|
||||
const eintraege = [
|
||||
@@ -64,6 +87,7 @@ async function visionErstellen() {
|
||||
try {
|
||||
features.value.push(mitKey(await featureAnlegen(
|
||||
props.projekt, '<!-- titel -->\nVision\n<!-- art -->\nvision\n')))
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
@@ -81,6 +105,7 @@ async function erstellen(titel) {
|
||||
features.value.push(f)
|
||||
sortiere()
|
||||
neuKey.value = f.key // öffnet die Karte im Vollmodus
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
@@ -92,26 +117,110 @@ async function entfernen() {
|
||||
await featureLoeschen(props.projekt, id)
|
||||
features.value = features.value.filter(f => f.id !== id)
|
||||
modal.value = null
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Eine vorgeschlagene Karte gibt es noch nicht — sie kann an keiner Karte hängen.
|
||||
// Darum steht sie als eigener Umriss im Gitter, mit demselben ✓ und ✕.
|
||||
const vorschlaege = computed(() => probleme.value.filter(p => p.feld === 'neue_karte'))
|
||||
|
||||
async function karteAnnehmen(p) {
|
||||
try {
|
||||
features.value.push(mitKey(await featureAnlegen(props.projekt, p.roh)))
|
||||
sortiere()
|
||||
erledige(p)
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Nacheinander, nicht gleichzeitig: die Dateinamen entstehen aus den Titeln, und
|
||||
// zwei parallele Anlagen könnten sich denselben aussuchen.
|
||||
async function alleAnnehmen() {
|
||||
for (const p of [...vorschlaege.value]) {
|
||||
await karteAnnehmen(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Gestrichene Karten haben keine Datei mehr. Sie kommen aus den Änderungen.
|
||||
const weggeplant = computed(() => sicht.value !== 'planung' ? [] :
|
||||
Object.entries(aenderungen.value).filter(([, a]) => a.art === 'weg').map(([t]) => t))
|
||||
|
||||
// Eine zurückgesetzte Karte kann verschwinden, wiederkommen oder sich ändern.
|
||||
// Welcher Fall es ist, weiß der Server — die Liste lädt darum neu.
|
||||
async function zurueck(titel) {
|
||||
if (await setzeZurueck(titel)) {
|
||||
await laden()
|
||||
emit('geaendert')
|
||||
}
|
||||
}
|
||||
|
||||
// Andere Projekte als Ziel; ins eigene zu verschieben ergäbe keinen Sinn
|
||||
const andereProjekte = computed(() =>
|
||||
props.projekte.map(p => p.name).filter(n => n !== props.projekt))
|
||||
|
||||
async function verschieben(ziel) {
|
||||
const id = modal.value.feature.id
|
||||
try {
|
||||
await featureVerschieben(props.projekt, id, ziel)
|
||||
features.value = features.value.filter(f => f.id !== id)
|
||||
modal.value = null
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.projekt, laden, { immediate: true })
|
||||
watch(() => props.stand, laden)
|
||||
// Der Reiter entscheidet, welchen Ordner der Server liest — also neu laden.
|
||||
watch(sicht, laden)
|
||||
|
||||
// Während einer Planung zeigt die Übersicht den Projektstand. Der ist nicht
|
||||
// bearbeitbar: eine Änderung landete sonst still in der Arbeitskopie.
|
||||
const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersicht')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bereich" @contextmenu.prevent="bereichsMenue">
|
||||
<div class="bereich" @contextmenu.prevent="nurLesen || bereichsMenue($event)">
|
||||
<div class="kopf">
|
||||
<input v-model="suche" class="suche" placeholder="Suchen …" type="search">
|
||||
<span v-if="suche.trim() && versteckt" class="dezent">{{ versteckt }} ausgeblendet</span>
|
||||
<!-- Die Werkzeuge gehören neben die Karten, an denen sie arbeiten.
|
||||
In der Projektleiste standen sie weit weg von ihrer Wirkung. -->
|
||||
<span class="werkzeuge"><slot name="werkzeuge" /></span>
|
||||
</div>
|
||||
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
|
||||
|
||||
<!-- Aus dem Code kommen zwanzig Karten auf einmal. Zwanzigmal ✓ zu klicken,
|
||||
nur um sie danach zu prüfen, ist Arbeit ohne Entscheidung. -->
|
||||
<p v-if="vorschlaege.length > 2" class="sammelzeile">
|
||||
<button class="knopf" @click="alleAnnehmen">
|
||||
Alle {{ vorschlaege.length }} Karten anlegen
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="gitter">
|
||||
<article v-for="p in vorschlaege" :key="p.vorschlag" class="karte umriss">
|
||||
<header>
|
||||
<h3>{{ p.vorschlag }}</h3>
|
||||
<button class="icon-knopf uebernehmen" title="Karte anlegen" @click="karteAnnehmen(p)">✓</button>
|
||||
<button class="icon-knopf rot" title="Vorschlag verwerfen" @click="erledige(p)">✕</button>
|
||||
</header>
|
||||
<p class="hinweis">Vorgeschlagene Karte</p>
|
||||
</article>
|
||||
|
||||
<VisionKarte
|
||||
v-if="vision"
|
||||
:key="vision.key"
|
||||
v-if="sichtbareVision"
|
||||
:key="sichtbareVision.key"
|
||||
:projekt="projekt"
|
||||
:feature="vision"
|
||||
:feature="sichtbareVision"
|
||||
@aktualisiert="aktualisiert"
|
||||
@entfernen="oeffneModal({ art: 'entfernen', feature: vision })"
|
||||
@entfernen="oeffneModal({ art: 'entfernen', feature: sichtbareVision })"
|
||||
/>
|
||||
<FeatureKarte
|
||||
v-for="f in karten"
|
||||
@@ -120,10 +229,28 @@ watch(() => props.projekt, laden, { immediate: true })
|
||||
:feature="f"
|
||||
:vollmodus="f.key === neuKey"
|
||||
:probleme="problemeFuer(f.id)"
|
||||
:verschiebbar="andereProjekte.length > 0"
|
||||
:planung="fuerKarte(f.sektionen.titel)"
|
||||
:nur-lesen="nurLesen"
|
||||
@aktualisiert="aktualisiert"
|
||||
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
|
||||
@verschieben="oeffneModal({ art: 'verschieben', feature: f })"
|
||||
@zuruecksetzen="zurueck(f.sektionen.titel)"
|
||||
/>
|
||||
|
||||
<!-- Weggeplante Karten stehen in keiner Datei mehr. Ohne eigenen Umriss
|
||||
wäre die Streichung unsichtbar und ließe sich nicht zurücknehmen. -->
|
||||
<article v-for="t in weggeplant" :key="`weg-${t}`" class="karte umriss weg">
|
||||
<header>
|
||||
<h3>{{ t }}</h3>
|
||||
<button class="icon-knopf" title="Streichung zurücknehmen" @click="zurueck(t)">↺</button>
|
||||
</header>
|
||||
<p class="hinweis">Wird gestrichen</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="!karten.length && !sichtbareVision && !meldung" class="leer">
|
||||
{{ suche.trim() ? 'Nichts gefunden.' : 'Noch keine Features. Rechtsklick zum Anlegen.' }}
|
||||
</p>
|
||||
|
||||
<ModalBox
|
||||
v-if="modal && modal.art === 'erstellen'"
|
||||
@@ -133,6 +260,15 @@ watch(() => props.projekt, laden, { immediate: true })
|
||||
@bestaetigt="erstellen"
|
||||
@abbrechen="modal = null"
|
||||
/>
|
||||
<ModalBox
|
||||
v-else-if="modal && modal.art === 'verschieben'"
|
||||
titel="Feature verschieben"
|
||||
:auswahl="andereProjekte"
|
||||
knopf="Verschieben"
|
||||
:meldung="meldung"
|
||||
@bestaetigt="verschieben"
|
||||
@abbrechen="modal = null"
|
||||
/>
|
||||
<ModalBox
|
||||
v-else-if="modal"
|
||||
:titel="`${modal.feature.sektionen.titel === 'Vision' ? 'Vision' : 'Feature'} entfernen`"
|
||||
@@ -147,6 +283,44 @@ watch(() => props.projekt, laden, { immediate: true })
|
||||
|
||||
<style scoped>
|
||||
.bereich { min-height: 100%; }
|
||||
.kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
max-width: 100rem;
|
||||
margin: 0 auto 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.suche { flex: 1; max-width: 22rem; min-width: 8rem; }
|
||||
.werkzeuge {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.dezent { font-size: 0.8rem; color: var(--dezent); }
|
||||
.leer { color: var(--dezent); text-align: center; margin-top: 2rem; }
|
||||
/* Vorgeschlagene Karte: gestrichelt, damit man sie nicht für eine echte hält */
|
||||
.sammelzeile { margin: 0 0 0.6rem; }
|
||||
.sammelzeile .knopf {
|
||||
border: 1px dashed var(--akzent);
|
||||
background: none;
|
||||
color: var(--akzent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sammelzeile .knopf:hover { background: var(--gruen-bg); }
|
||||
.umriss { border-style: dashed; border-color: var(--akzent); }
|
||||
.umriss.weg { border-color: var(--rot); }
|
||||
.umriss.weg h3 { text-decoration: line-through; color: var(--rot); }
|
||||
.umriss header { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.umriss h3 { flex: 1; margin: 0; font-size: 0.95rem; }
|
||||
.umriss .hinweis { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--dezent); }
|
||||
.uebernehmen:hover { color: var(--gruen); background: var(--gruen-bg); }
|
||||
/* Spaltenlayout: unterschiedlich hohe Karten stapeln sich ohne Lücken.
|
||||
Die Breite ist gedeckelt — auf sehr breiten Schirmen klumpten die Karten sonst
|
||||
links und rechts blieb eine tote Fläche. */
|
||||
|
||||
@@ -4,18 +4,23 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
||||
const props = defineProps({
|
||||
titel: { type: String, required: true },
|
||||
frage: { type: String, default: '' }, // gesetzt = Rückfrage statt Eingabe
|
||||
auswahl: { type: Array, default: null }, // gesetzt = Liste statt freier Text
|
||||
vorgabe: { type: String, default: '' }, // steht schon im Feld
|
||||
leerErlaubt: { type: Boolean, default: false }, // leer heißt hier: zurücksetzen
|
||||
platzhalter: { type: String, default: '' },
|
||||
knopf: { type: String, default: 'Bestätigen' },
|
||||
meldung: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['bestaetigt', 'abbrechen'])
|
||||
|
||||
const wert = ref('')
|
||||
const wert = ref(props.vorgabe || props.auswahl?.[0] || '')
|
||||
const feld = ref(null)
|
||||
|
||||
// Rückfrage braucht keine Eingabe; Text und Auswahl schon
|
||||
const bereit = () => !!props.frage || props.leerErlaubt || !!wert.value.trim()
|
||||
|
||||
function bestaetigen() {
|
||||
if (!props.frage && !wert.value.trim()) return
|
||||
emit('bestaetigt', wert.value.trim())
|
||||
if (bereit()) emit('bestaetigt', wert.value.trim())
|
||||
}
|
||||
|
||||
// Am Fenster statt am Eingabefeld: die Rückfrage hat gar keins
|
||||
@@ -35,6 +40,9 @@ onUnmounted(() => window.removeEventListener('keydown', beiTaste))
|
||||
<div class="modal karte">
|
||||
<h2>{{ titel }}</h2>
|
||||
<p v-if="frage" class="frage">{{ frage }}</p>
|
||||
<select v-else-if="auswahl" ref="feld" v-model="wert" @keyup.enter="bestaetigen">
|
||||
<option v-for="a in auswahl" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
ref="feld"
|
||||
@@ -45,7 +53,7 @@ onUnmounted(() => window.removeEventListener('keydown', beiTaste))
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
<footer>
|
||||
<button @click="emit('abbrechen')">Abbrechen</button>
|
||||
<button class="primaer" :disabled="!frage && !wert.trim()" @click="bestaetigen">{{ knopf }}</button>
|
||||
<button class="primaer" :disabled="!bereit()" @click="bestaetigen">{{ knopf }}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { art, laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } fr
|
||||
|
||||
const UEBERSCHRIFT = {
|
||||
stil: 'Stilprüfung',
|
||||
feature: 'Features im Code',
|
||||
code: 'Code in Features',
|
||||
}
|
||||
|
||||
// Nur die erste Zeile — mehrzeilige Stellen liefen hier sonst zu einem Klumpen zusammen
|
||||
|
||||
@@ -14,7 +14,7 @@ const meldung = ref('')
|
||||
const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird
|
||||
const umbenennung = ref('')
|
||||
const umbenennFeld = ref(null)
|
||||
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', name}
|
||||
const modal = ref(null) // {art: 'erstellen'|'entfernen', name?}
|
||||
|
||||
async function starteUmbenennen(name) {
|
||||
meldung.value = ''
|
||||
@@ -77,10 +77,6 @@ async function entfernen() {
|
||||
|
||||
<template>
|
||||
<aside @contextmenu.prevent="leistenMenue">
|
||||
<header>
|
||||
<p class="gruppe">Projekte</p>
|
||||
<span class="werkzeuge"><slot /></span>
|
||||
</header>
|
||||
<ul>
|
||||
<li
|
||||
v-for="p in projekte"
|
||||
@@ -135,20 +131,6 @@ aside {
|
||||
overflow-y: auto; /* lange Projektlisten scrollen für sich */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
/* Werkzeuge rechts zusammen, nicht über die Breite verteilt */
|
||||
.werkzeuge {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 46rem) {
|
||||
aside {
|
||||
border-right: none;
|
||||
@@ -160,14 +142,6 @@ header {
|
||||
li { padding: 0.25rem 0.7rem; background: var(--bg); }
|
||||
.name { flex: none; }
|
||||
}
|
||||
.gruppe {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dezent);
|
||||
margin: 0;
|
||||
}
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
li {
|
||||
display: flex;
|
||||
|
||||
BIN
frontend/src/components/k1-1024.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/src/components/k1-1280.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/src/components/k1-1600.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/src/components/k1-390.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/src/components/k1-640.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/src/components/k1-820.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/src/components/komfort.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/src/components/offen.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -18,3 +18,9 @@ window.addEventListener('click', (e) => {
|
||||
if (e.target.closest?.('.kontextmenue')) return // Auswahl darf zuerst laufen
|
||||
schliesseMenue()
|
||||
}, true)
|
||||
|
||||
// Escape kommt vor der Maus: wer sich vertippt hat, will die Hand nicht bewegen.
|
||||
// Ebenfalls in der Capture-Phase, damit ein offenes Eingabefeld es nicht abfängt.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && kontextMenue.value) schliesseMenue()
|
||||
}, true)
|
||||
|
||||
104
frontend/src/composables/planung.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
fetchPlanung, planungStarten, planungUmsetzen, planungAbbrechen,
|
||||
planungZuruecksetzen,
|
||||
} from '../api.js'
|
||||
|
||||
// Zwei Sichten auf dieselben Karten. „Übersicht" sagt, was das Projekt kann.
|
||||
// „Planung" zeigt denselben Stand mit den geplanten Änderungen — eine geplante
|
||||
// Funktion gehört nicht in die Übersicht, sie wäre dort gelogen.
|
||||
//
|
||||
// Der Zustand ist global, weil Leiste, Liste und jede Karte dieselben Markierungen
|
||||
// brauchen. Er kommt vom Server: die Planung liegt auf der Platte und überlebt
|
||||
// einen Neustart.
|
||||
export const laeuft = ref(false)
|
||||
export const aenderungen = ref({})
|
||||
export const sicht = ref('uebersicht')
|
||||
export const meldung = ref('')
|
||||
|
||||
let projektName = null
|
||||
|
||||
export const anzahl = computed(() => Object.keys(aenderungen.value).length)
|
||||
|
||||
// Was eine Karte in der Planung ist: neu, weg, geändert — oder nichts.
|
||||
export function fuerKarte(titel) {
|
||||
return sicht.value === 'planung' ? aenderungen.value[titel] || null : null
|
||||
}
|
||||
|
||||
function uebernehmen(stand) {
|
||||
const vorher = laeuft.value
|
||||
laeuft.value = !!stand.laeuft
|
||||
aenderungen.value = stand.aenderungen || {}
|
||||
// Ohne Planung gibt es nichts zu sehen; die Sicht fällt zurück.
|
||||
if (!laeuft.value) sicht.value = 'uebersicht'
|
||||
// Läuft eine, ist sie der Grund, warum man da ist. Nach einem Neustart oder
|
||||
// einem Projektwechsel landet man darin, nicht in der Übersicht.
|
||||
else if (!vorher) sicht.value = 'planung'
|
||||
}
|
||||
|
||||
export async function lade(projekt) {
|
||||
projektName = projekt
|
||||
meldung.value = ''
|
||||
if (!projekt) {
|
||||
uebernehmen({})
|
||||
return
|
||||
}
|
||||
try {
|
||||
uebernehmen(await fetchPlanung(projekt))
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
export async function starte() {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
try {
|
||||
uebernehmen(await planungStarten(projektName))
|
||||
sicht.value = 'planung'
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Umsetzen und Abbrechen enden beide damit, dass es keine Planung mehr gibt.
|
||||
// Der Aufrufer lädt danach die Karten neu — beim Umsetzen haben sie sich geändert.
|
||||
export async function umsetzen() {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
try {
|
||||
await planungUmsetzen(projektName)
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return false
|
||||
}
|
||||
await lade(projektName)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function brichAb() {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
try {
|
||||
await planungAbbrechen(projektName)
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return false
|
||||
}
|
||||
await lade(projektName)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function setzeZurueck(titel) {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
try {
|
||||
uebernehmen(await planungZuruecksetzen(projektName, titel))
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -18,8 +18,11 @@ let uhr = null
|
||||
export const prozent = computed(() =>
|
||||
gesamt.value ? Math.round((fertig.value / gesamt.value) * 100) : 0)
|
||||
|
||||
// Der Vorschlag gehört dazu: eine vorgeschlagene Karte hängt an keinem Feature, an
|
||||
// keiner Funktion und ersetzt keine Zeile. Ohne ihn hätten zwanzig neue Karten
|
||||
// denselben Schlüssel — angezeigt wurde dann genau eine.
|
||||
export function schluessel(p) {
|
||||
return `${p.feature}|${p.feld}|${p.funktion}|${p.original}`
|
||||
return `${p.feature}|${p.feld}|${p.funktion}|${p.original}|${p.vorschlag}`
|
||||
}
|
||||
|
||||
export function problemeFuer(featureId) {
|
||||
@@ -86,6 +89,19 @@ function uebernehmen(stand) {
|
||||
}
|
||||
}
|
||||
|
||||
// Vorschläge von außen — das Gespräch liefert dieselbe Form wie die Prüfung.
|
||||
// Eigene Anzeige wäre doppelt: die Karten wissen schon, wie ein Vorschlag aussieht.
|
||||
// Das Panel bleibt dabei zu: es aufzuklappen verdrängte das Gespräch aus der Spalte,
|
||||
// mitten im Satz.
|
||||
export function ergaenze(neue) {
|
||||
for (const p of neue) {
|
||||
const k = schluessel(p)
|
||||
if (gesehen.has(k)) continue
|
||||
gesehen.add(k)
|
||||
probleme.value.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein angenommener Titelvorschlag benennt Karte oder Funktion um. Die übrigen Probleme
|
||||
// zeigen dann noch auf den alten Namen und wären in der Karte nicht mehr auffindbar.
|
||||
export function benenneUm(featureId, feld, alt, neu) {
|
||||
@@ -142,6 +158,12 @@ export function erledige(problem) {
|
||||
probleme.value = probleme.value.filter(p => p !== problem)
|
||||
}
|
||||
|
||||
// Abgelehnt heißt: aus der Liste nehmen. Ein Gedächtnis dafür gab es nur für die
|
||||
// Rückwärtsprüfung; die ist weg.
|
||||
export function verwirf(problem) {
|
||||
erledige(problem)
|
||||
}
|
||||
|
||||
export async function schliesse() {
|
||||
stoppeUhr()
|
||||
offen.value = false
|
||||
|
||||