// ----------------------------- // 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 `${esc(def.label)}`; } // auftragsstatusOptionsHtml: die ` ).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 ``; } // 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 => ``).join(''); } // Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung" // steht: Ein beauftragtes Projekt gehört nicht mehr in die Anbahnung. 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); }