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>
289 lines
14 KiB
JavaScript
289 lines
14 KiB
JavaScript
// -----------------------------
|
||
// 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);
|
||
}
|