Files
projektuebersicht/public/js/auftrag.js
T
erikandClaude Sonnet 5 db63a29365 Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 15:14:47 +02:00

289 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektübersicht — Auftragsstatus & Beauftragung
// -----------------------------
// Der Weg von der Anfrage zum beauftragten Projekt. Bisher entstand ein Projekt
// über "+ Neues Projekt" (saveNewProj(), js/load/load.js) und war damit
// stillschweigend beauftragt; die Gruppe "In Anbahnung" deutete einen
// Akquise-Zustand nur an. Es fehlte der Übergang "Angebot -> beauftragt",
// und genau an dem hängen die beiden Angaben, ohne die der Terminplan aus dem
// Honorar nicht rechnen kann (js/plan/plan-honorar.js): das vereinbarte
// Netto-Honorar (p.honorar) und die beauftragten Leistungsphasen
// (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM
// Schritt — direkt danach ist der Terminplan rechenbar.
//
// Geladen von App.html als gemeinsames Modul (klassisches Skript, alle
// Funktionen liegen dadurch auf window). Genutzt werden LPHS aus
// js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty()
// aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus
// js/gantt.js.
//
// Anzeige-Bausteine für andere Module (werden dort eingehängt, dieses Modul
// rendert selbst keine Projektzeile):
// auftragsstatusChip(p) -> Kennzeichen für die Projektzeile (HTML)
// auftragsstatusSelectHtml(p) -> Auswahlfeld für die Pflegemasken (HTML)
// setAuftragsstatus(pid, wert) -> Schreibweg hinter dem Auswahlfeld
// AUFTRAGSSTATUS: die Abfolge von der Anfrage bis zum Abschluss. Reihenfolge =
// fachliche Abfolge (sie bestimmt die Reihenfolge im Auswahlfeld). Dieselben
// Schlüssel stehen in VALID_AUFTRAGSSTATUS in server.js — bewusst dupliziert,
// es gibt keinen Bundler. `hinweis` erklärt im Auswahlfeld, was der Zustand
// bedeutet; der Unterschied Anfrage/Angebot ist sonst Auslegungssache.
const AUFTRAGSSTATUS = [
{ key: 'anfrage', label: 'Anfrage', hinweis: 'Angefragt, es liegt noch kein Angebot vor.' },
{ key: 'angebot', label: 'Angebot', hinweis: 'Angebot abgegeben, Entscheidung offen.' },
{ key: 'beauftragt', label: 'Beauftragt', hinweis: 'Auftrag erteilt — Honorar und beauftragte Leistungsphasen liegen vor.' },
{ key: 'abgeschlossen', label: 'Abgeschlossen', hinweis: 'Leistung erbracht und abgerechnet.' }
];
// AUFTRAGSSTATUS_VORGABE: gilt für alles, was keinen Status trägt. Wie
// serverseitig (DEFAULT_AUFTRAGSSTATUS) 'beauftragt', denn der Altbestand ist
// beauftragte Arbeit — jeder andere Anfangswert hätte laufende Projekte
// rückwirkend zu Akquise erklärt.
const AUFTRAGSSTATUS_VORGABE = 'beauftragt';
// auftragsstatusOf: Status eines Projekts, immer ein gültiger Schlüssel.
// Parameter: p = Projektobjekt. Rückgabe: Schlüssel aus AUFTRAGSSTATUS.
function auftragsstatusOf(p){
const v = String((p && p.auftragsstatus) || '').trim();
return AUFTRAGSSTATUS.some(s => s.key === v) ? v : AUFTRAGSSTATUS_VORGABE;
}
// auftragsstatusDef: Definitionsobjekt zu einem Schlüssel (nie null).
function auftragsstatusDef(key){
return AUFTRAGSSTATUS.find(s => s.key === key)
|| AUFTRAGSSTATUS.find(s => s.key === AUFTRAGSSTATUS_VORGABE);
}
// auftragsstatusLabel: lesbare Bezeichnung eines Schlüssels ('angebot' ->
// 'Angebot').
function auftragsstatusLabel(key){
return auftragsstatusDef(key).label;
}
// auftragsstatusChip: das Kennzeichen für die Projektzeile (fertiges HTML).
// Parameter: p = Projektobjekt, opts.immer = true zeigt das Kennzeichen auch
// im Normalzustand "beauftragt".
// Rückgabe: HTML-Schnipsel; bei 'beauftragt' standardmäßig ein LEERER String —
// das ist der Regelfall (und die Vorgabe des gesamten Bestands), ein
// Kennzeichen an jeder einzelnen Zeile wäre also nur Rauschen. Sichtbar ist
// damit genau das Besondere: Anfrage, Angebot, Abgeschlossen.
function auftragsstatusChip(p, opts){
const o = opts || {};
const key = auftragsstatusOf(p);
if(key === 'beauftragt' && !o.immer) return '';
const def = auftragsstatusDef(key);
return `<span class="auftrag-chip auftrag-chip-${key}" title="Auftragsstatus: ${esc(def.label)} — ${esc(def.hinweis)}">${esc(def.label)}</span>`;
}
// auftragsstatusOptionsHtml: die <option>-Liste für ein Auswahlfeld.
// Parameter: gewaehlt = Schlüssel des vorausgewählten Status.
function auftragsstatusOptionsHtml(gewaehlt){
const sel = AUFTRAGSSTATUS.some(s => s.key === gewaehlt) ? gewaehlt : AUFTRAGSSTATUS_VORGABE;
return AUFTRAGSSTATUS.map(s =>
`<option value="${s.key}"${s.key === sel ? ' selected' : ''} title="${esc(s.hinweis)}">${esc(s.label)}</option>`
).join('');
}
// auftragsstatusSelectHtml: das Auswahlfeld für die Masken, in denen ein
// Projekt gepflegt wird (fertiges HTML, schreibt über setAuftragsstatus()).
// Parameter: p = Projektobjekt, opts.klasse = zusätzliche CSS-Klasse,
// opts.disabled = true für gesperrte Sitzungen.
function auftragsstatusSelectHtml(p, opts){
const o = opts || {};
const klasse = 'auftrag-select' + (o.klasse ? ' ' + o.klasse : '');
return `<select class="${klasse}"${o.disabled ? ' disabled' : ''}
title="Auftragsstatus des Projekts"
onchange="setAuftragsstatus('${esc(String((p && p.id) || ''))}', this.value)"
>${auftragsstatusOptionsHtml(auftragsstatusOf(p))}</select>`;
}
// auftragProjekt: findet Projekt UND zugehörige Sitzung zu einer Projekt-ID.
// Die Sitzung wird mitgeliefert, weil nur sie gespeichert werden kann
// (markWeekDirty) und weil ihr Sperr-Zustand darüber entscheidet, ob
// überhaupt geschrieben werden darf. Zuerst wird in der ausgewählten Sitzung
// gesucht; der Projektbereich kann über einen Deep-Link aber auch eine andere
// Sitzung zeigen, deshalb die Suche über alle.
// Rückgabe: { week, project } oder null.
function auftragProjekt(pid){
const w = currentWeek();
const p = (w && Array.isArray(w.projects)) ? w.projects.find(x => x.id === pid) : null;
if(p) return { week: w, project: p };
for(const week of (db.weeks || [])){
const found = (week.projects || []).find(x => x.id === pid);
if(found) return { week, project: found };
}
return null;
}
// auftragGesperrt: Sperr-Prüfung für eine beliebige Sitzung (guardWeekLocked()
// prüft immer nur die ausgewählte, siehe auftragProjekt()).
// Rückgabe: true, wenn NICHT geschrieben werden darf (mit Hinweis).
function auftragGesperrt(week){
if(isWeekLocked(week)){
alert('Diese Sitzung ist gesperrt. Bitte entsperren Sie sie zuerst.');
return true;
}
return false;
}
// auftragSpeichern: schreibt eine Änderung an einem Projekt fort und rendert
// alle Ansichten neu, die den Status oder das Honorar zeigen. save({week:false})
// plus eigenes markWeekDirty(), weil die geänderte Sitzung nicht zwingend die
// ausgewählte ist (siehe auftragProjekt()).
function auftragSpeichern(week, project){
markWeekDirty(week.label);
save({ week: false });
renderBoard(); renderProjects(); renderLoad();
// Der Projektbereich zeigt Honorar und beauftragte Leistungsphasen in seiner
// Einstellungsmaske. Er ist nur gerendert, wenn dort ein Projekt offen ist —
// und nur dieses eine ist von der Änderung betroffen.
if(window.PlanApp && PlanApp.project === project){
if(typeof renderPlanSettings === 'function') renderPlanSettings();
if(typeof renderHonorarEingaben === 'function') renderHonorarEingaben();
}
}
// setAuftragsstatus: Schreibweg hinter jedem Auswahlfeld (siehe
// auftragsstatusSelectHtml). Der Sprung NACH 'beauftragt' führt bewusst nicht
// direkt in die Daten, sondern in den Dialog "Beauftragung": Zur Beauftragung
// gehören Honorar und beauftragte Leistungsphasen, und würde man sie hier
// überspringen, stünde das Projekt als beauftragt da, ohne dass sich ein
// Terminplan rechnen ließe.
// Parameter: pid = Projekt-ID, wert = Schlüssel aus AUFTRAGSSTATUS.
function setAuftragsstatus(pid, wert){
const treffer = auftragProjekt(pid);
if(!treffer) return;
if(auftragGesperrt(treffer.week)){
// Die Auswahl im Feld steht schon auf dem neuen Wert — neu rendern, damit
// wieder der tatsächlich gespeicherte Status zu sehen ist.
renderBoard(); renderProjects();
return;
}
if(!AUFTRAGSSTATUS.some(s => s.key === wert)) return; // unbekannter Wert: nichts tun
const alt = auftragsstatusOf(treffer.project);
if(wert === alt) return;
if(wert === 'beauftragt'){ openBeauftragungModal(pid); return; }
treffer.project.auftragsstatus = wert;
auftragSpeichern(treffer.week, treffer.project);
}
// ---------- Dialog "Beauftragung" ----------
// beauftragungProjectId: Projekt-ID des offenen Beauftragungs-Dialogs
// (null = geschlossen).
let beauftragungProjectId = null;
// openBeauftragungModal: öffnet den Dialog "Beauftragung" für ein Projekt.
// Parameter: pid = Projekt-ID. Gesperrte Sitzungen werden abgewiesen.
function openBeauftragungModal(pid){
const treffer = auftragProjekt(pid);
if(!treffer) return;
if(auftragGesperrt(treffer.week)) return;
const p = treffer.project;
beauftragungProjectId = pid;
const titelEl = document.getElementById('beauftragungProjekt');
if(titelEl) titelEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || '');
const hinweisEl = document.getElementById('beauftragungStatusHinweis');
if(hinweisEl) hinweisEl.textContent = 'Bisheriger Status: ' + auftragsstatusLabel(auftragsstatusOf(p));
const betragEl = document.getElementById('beauftragungHonorar');
if(betragEl) betragEl.value = Number(p.honorar) > 0 ? p.honorar : '';
// Dieselbe Auswahl wie im Honorar-Dialog (renderHonorarEingaben(),
// js/plan/plan-honorar.js) und im Neues-Projekt-Dialog, damit das Feld
// überall gleich aussieht und bedient wird.
const lpEl = document.getElementById('beauftragungBeauftragteLp');
if(lpEl){
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
<input type="checkbox" value="${esc(l)}"${gesetzt.has(l) ? ' checked' : ''}> ${esc(l)}
</label>`).join('');
}
// Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung"
// steht: Diese Gruppe wird von der Auslastungsrechnung ausdrücklich NICHT
// eingerechnet (berechneAuslastung(), js/load/load.js). Bliebe ein
// beauftragtes Projekt dort liegen, zählte die Arbeit daran nirgends mit.
const gruppeWrap = document.getElementById('beauftragungGruppeWrap');
const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none';
if(gruppeCheck) gruppeCheck.checked = (p.group === 'In Anbahnung');
document.getElementById('beauftragungModal').style.display = 'flex';
}
// closeBeauftragungModal: schließt den Dialog. Gerendert wird trotzdem neu,
// weil der Weg hierher über ein Auswahlfeld führen kann, das bereits auf
// "Beauftragt" steht, während gespeichert noch der alte Status ist
// (siehe setAuftragsstatus()).
function closeBeauftragungModal(){
document.getElementById('beauftragungModal').style.display = 'none';
beauftragungProjectId = null;
renderBoard(); renderProjects();
}
// saveBeauftragung: übernimmt Honorar und beauftragte Leistungsphasen, setzt
// den Status auf 'beauftragt' und speichert.
// Parameter: zumTerminplan = true öffnet danach den Projektbereich, damit der
// Terminplan sofort aus dem Honorar gerechnet werden kann ("+ Terminplan").
function saveBeauftragung(zumTerminplan){
const treffer = auftragProjekt(beauftragungProjectId);
if(!treffer) return;
if(auftragGesperrt(treffer.week)) return;
const p = treffer.project;
// Honorar streng prüfen statt parseFloat: Das nahm auch "50t" (-> 50)
// kommentarlos an — bei einer Zahl, aus der später ein ganzer Terminplan
// gerechnet wird, ist das keine Kleinigkeit. Das Feld ist ein Zahlenfeld,
// liefert also bereits Punkt-Dezimalschreibweise; die Komma-Ersetzung ist
// nur die Rückfallebene für Browser, die das Komma durchlassen.
const roh = String((document.getElementById('beauftragungHonorar') || {}).value || '')
.trim().replace(/\s/g, '').replace(',', '.');
let honorar = 0;
if(roh !== ''){
if(!/^\d+(\.\d+)?$/.test(roh)){
alert('Bitte das Honorar als reine Zahl in Euro eintragen (z. B. 48500 oder 48500,50).');
return;
}
honorar = parseFloat(roh);
}
const lpEl = document.getElementById('beauftragungBeauftragteLp');
const gewaehlt = lpEl
? new Set(Array.from(lpEl.querySelectorAll('input:checked')).map(cb => cb.value))
: new Set();
if(gewaehlt.size === 0){
alert('Bitte mindestens eine beauftragte Leistungsphase wählen.\n\n'
+ 'Der Terminplan aus dem Honorar verteilt das Honorar genau auf diese Phasen.');
return;
}
// Fehlendes Honorar wird nicht erzwungen — es ist bei der Beauftragung nicht
// immer schon abschließend vereinbart. Der Preis dafür wird aber benannt:
// ohne Honorar rechnet der Terminplan nicht.
if(honorar <= 0 && !confirm('Ohne vereinbartes Honorar kann kein Terminplan aus dem Honorar '
+ 'berechnet werden.\n\nTrotzdem als beauftragt kennzeichnen?')) return;
// Beauftragte Phasen in der Reihenfolge von LPHS ablegen (gleiche Konvention
// wie toggleBeauftragteLp() im Honorar-Dialog und saveNewProj()).
p.beauftragteLP = LPHS.filter(l => gewaehlt.has(l));
p.honorar = honorar;
p.auftragsstatus = 'beauftragt';
const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
if(p.group === 'In Anbahnung' && gruppeCheck && gruppeCheck.checked){
p.group = 'Aktive Projekte';
// Neue Reihenfolge in der Zielgruppe, sonst teilt das Projekt seine
// Ordnungszahl mit einem bereits dort liegenden (order ist nur INNERHALB
// einer Gruppe eindeutig).
p.order = (treffer.week.projects || [])
.filter(x => x !== p && x.group === 'Aktive Projekte')
.reduce((max, x) => Math.max(max, typeof x.order === 'number' ? x.order : 0), -1) + 1;
}
const pid = p.id;
auftragSpeichern(treffer.week, p);
document.getElementById('beauftragungModal').style.display = 'none';
beauftragungProjectId = null;
if(zumTerminplan) openProjectPlan(pid);
}