This commit is contained in:
team3
2026-07-29 15:19:41 +02:00
parent 2e86d682eb
commit 5d42b4cfc5
155 changed files with 14482 additions and 1736 deletions

BIN
frontend/k1-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
frontend/k1-1280.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
frontend/k1-1600.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
frontend/k1-390.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
frontend/k1-640.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
frontend/k1-820.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
frontend/komfort.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
frontend/offen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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>

View File

@@ -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' })

View 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>

View File

@@ -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

View File

@@ -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. */

View File

@@ -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>

View File

@@ -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

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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)

View 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
}

View File

@@ -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