From 5361800ca971d8b0631d0b36d56b75d05d8887dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 19:53:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(roadmaps):=20Kernlogik=20+=20Appwrite-Kons?= =?UTF-8?q?tanten=20f=C3=BCr=20projektspezifische=20Pl=C3=A4ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/lib/appwrite.js | 3 + src/lib/roadmaps.js | 292 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 src/lib/roadmaps.js diff --git a/src/lib/appwrite.js b/src/lib/appwrite.js index 3d2a85a..5ebf895 100644 --- a/src/lib/appwrite.js +++ b/src/lib/appwrite.js @@ -43,9 +43,12 @@ export const COLLECTIONS = { LEADS: 'leads', LEAD_SETTINGS: 'leadSettings', LEAD_PIPELINE: 'leadPipeline', + ROADMAPS: 'roadmaps', + ROADMAP_NODES: 'roadmapNodes', } export const WEBPAGE_TICKET_TYPE = 'Webpage' +export const PROJECT_TICKET_TYPE = 'Project' export const PREVIEW_TEMPLATE_NAME = 'webklar-preview-template' export const BUCKET_ID = import.meta.env.VITE_APPWRITE_BUCKET_ID || 'woms-attachments' diff --git a/src/lib/roadmaps.js b/src/lib/roadmaps.js new file mode 100644 index 0000000..8047629 --- /dev/null +++ b/src/lib/roadmaps.js @@ -0,0 +1,292 @@ +import { databases, DATABASE_ID, COLLECTIONS, PROJECT_TICKET_TYPE, Query, ID } from './appwrite' + +/** + * Roadmaps: projektspezifische Plaene als Steckbrett (Spalten-Raster). + * Ein Dokument pro Knoten; Kanten als sourceIds (Vorgaenger) am Zielknoten. + * Genau 1 aktiver Plan pro Projekt (Pruefung hier, nicht per Index erzwingbar). + * Jeder Plan erzeugt automatisch ein Projektticket (Vorbild: createAcquisitionTicket in leads.js). + */ + +export const ROADMAP_STATUS = { ACTIVE: 'active', COMPLETED: 'completed' } +export const NODE_STATUS = { OPEN: 'open', IN_PROGRESS: 'in_progress', DONE: 'done' } +export const NODE_KIND = { START: 'start', STEP: 'step', GOAL: 'goal' } + +export const NODE_STATUS_LABELS = { + open: 'Offen', + in_progress: 'In Arbeit', + done: 'Fertig', +} + +/** Fortschritt in Prozent: fertige Knoten / alle Knoten ausser Start. */ +export function computeProgress(nodes) { + const relevant = (nodes || []).filter((n) => n.kind !== NODE_KIND.START) + if (!relevant.length) return { percent: 0, done: 0, total: 0 } + const done = relevant.filter((n) => n.status === NODE_STATUS.DONE).length + return { percent: Math.round((done / relevant.length) * 100), done, total: relevant.length } +} + +export function allGoalsDone(nodes) { + const goals = (nodes || []).filter((n) => n.kind === NODE_KIND.GOAL) + return goals.length > 0 && goals.every((n) => n.status === NODE_STATUS.DONE) +} + +export function isNodeOverdue(node) { + if (!node?.deadline || node.status === NODE_STATUS.DONE) return false + return node.deadline < new Date().toISOString().slice(0, 10) +} + +/** Aktiver Plan eines Projekts (oder null). */ +export async function fetchActiveRoadmapByProject(projectId) { + const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [ + Query.equal('projectId', projectId), + Query.equal('status', ROADMAP_STATUS.ACTIVE), + Query.limit(1), + ]) + return res.documents[0] || null +} + +export async function fetchRoadmapByTicketId(ticketId) { + if (!ticketId) return null + const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [ + Query.equal('ticketId', ticketId), + Query.limit(1), + ]) + return res.documents[0] || null +} + +export async function fetchRoadmapNodes(roadmapId) { + const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, [ + Query.equal('roadmapId', roadmapId), + Query.limit(500), + ]) + return res.documents +} + +async function nextWoid() { + try { + const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [ + Query.orderDesc('$createdAt'), + Query.limit(200), + ]) + const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0) + return String((nums.length ? Math.max(...nums) : 9999) + 1) + } catch { + return String(10000) + } +} + +async function nextWsid() { + try { + const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [ + Query.orderDesc('wsid'), + Query.limit(1), + ]) + const highest = parseInt(res.documents[0]?.wsid) + return String(isNaN(highest) ? 100000 : highest + 1) + } catch { + return String(100000) + } +} + +/** Automatisches Projektticket fuer einen neuen Plan. */ +async function createRoadmapTicket(roadmap, project, user) { + const woid = await nextWoid() + const data = { + topic: `Projekt: ${project.projectName}`, + status: 'Open', + priority: 2, + woid, + type: PROJECT_TICKET_TYPE, + systemType: 'n/a', + serviceType: 'Remote', + assignedTo: user?.$id || '', + details: + `Automatisch erstelltes Projektticket fuer die Roadmap "${roadmap.title}" ` + + `zum Projekt "${project.projectName}".\n\n` + + `Der Planfortschritt wird hier getrackt: Jede Statusaenderung eines Roadmap-Knotens ` + + `erzeugt einen Verlaufseintrag (Worksheet). Sind alle Ziel-Knoten fertig, wird das ` + + `Ticket geschlossen und der Plan archiviert.`, + createdAt: new Date().toISOString(), + } + if (project.customerId) { + data.customerId = project.customerId + if (roadmap.customerName) data.customerName = roadmap.customerName + } + return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data) +} + +/** + * Legt einen neuen Plan an: Roadmap-Dokument + Start-Knoten + Ziel-Knoten + Auto-Ticket. + * Wirft, wenn das Projekt bereits einen aktiven Plan hat. + */ +export async function createRoadmapPlan(project, { title, goalTitle }, user) { + const existing = await fetchActiveRoadmapByProject(project.$id) + if (existing) { + throw new Error(`Das Projekt "${project.projectName}" hat bereits einen aktiven Plan.`) + } + + let customerName = '' + if (project.customerId) { + try { + const customer = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, project.customerId) + customerName = customer.name || '' + } catch { + /* Kunde optional */ + } + } + + const now = new Date().toISOString() + const columnCount = 3 + const roadmap = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, ID.unique(), { + projectId: project.$id, + projectName: project.projectName || '', + customerId: project.customerId || '', + customerName, + title: title || `Plan: ${project.projectName}`, + status: ROADMAP_STATUS.ACTIVE, + columnCount, + createdBy: user?.$id || '', + createdAt: now, + updatedAt: now, + }) + + await Promise.all([ + databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), { + roadmapId: roadmap.$id, + title: 'Start', + kind: NODE_KIND.START, + column: 0, + row: 0, + status: NODE_STATUS.OPEN, + createdAt: now, + updatedAt: now, + }), + databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), { + roadmapId: roadmap.$id, + title: goalTitle || 'Ziel', + kind: NODE_KIND.GOAL, + column: columnCount - 1, + row: 0, + status: NODE_STATUS.OPEN, + createdAt: now, + updatedAt: now, + }), + ]) + + let ticket = null + try { + ticket = await createRoadmapTicket(roadmap, project, user) + await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, { + ticketId: ticket.$id, + woid: ticket.woid, + }) + roadmap.ticketId = ticket.$id + roadmap.woid = ticket.woid + } catch { + /* Ticket ist optional - Plan bleibt bestehen */ + } + return { roadmap, ticket } +} + +/** Verlaufseintrag (Worksheet-Kommentar) am Projektticket, ohne den Ticket-Status zu aendern. */ +export async function logRoadmapWorksheet(roadmap, detailsText, user) { + if (!roadmap?.ticketId) return null + let ticket + try { + ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId) + } catch { + return null // Ticket geloescht -> kein Log + } + const today = new Date().toISOString().slice(0, 10) + const wsid = await nextWsid() + return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), { + wsid, + woid: ticket.woid || roadmap.woid || '', + workorderId: ticket.$id, + employeeId: user?.$id || 'system', + employeeName: user?.name || user?.email || 'System', + serviceType: 'Remote', + oldStatus: ticket.status, + newStatus: ticket.status, + totalTime: 0, + startDate: today, + endDate: today, + details: detailsText, + isComment: true, + createdAt: new Date().toISOString(), + }) +} + +/** Knoten-Statuswechsel im Ticketverlauf dokumentieren. */ +export async function logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user) { + const from = NODE_STATUS_LABELS[oldStatus] || oldStatus || '-' + const to = NODE_STATUS_LABELS[newStatus] || newStatus + return logRoadmapWorksheet(roadmap, `Roadmap-Knoten "${node.title}": ${from} -> ${to}`, user) +} + +/** + * Plan abschliessen: Roadmap archivieren, Ticket schliessen (falls noch offen) + * und den Abschluss im Verlauf dokumentieren. + */ +export async function completeRoadmap(roadmap, user) { + const now = new Date().toISOString() + const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, { + status: ROADMAP_STATUS.COMPLETED, + completedAt: now, + updatedAt: now, + }) + if (roadmap.ticketId) { + try { + const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId) + if (ticket.status !== 'Closed') { + const wsid = await nextWsid() + const today = now.slice(0, 10) + await databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), { + wsid, + woid: ticket.woid || '', + workorderId: ticket.$id, + employeeId: user?.$id || 'system', + employeeName: user?.name || user?.email || 'System', + serviceType: 'Remote', + oldStatus: ticket.status, + newStatus: 'Closed', + totalTime: 0, + startDate: today, + endDate: today, + details: `Roadmap "${roadmap.title}" abgeschlossen: alle Ziel-Knoten fertig. Ticket automatisch geschlossen.`, + isComment: true, + createdAt: now, + }) + await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Closed' }) + } else { + await logRoadmapWorksheet({ ...roadmap, status: ROADMAP_STATUS.COMPLETED }, `Roadmap "${roadmap.title}" abgeschlossen (Ticket war bereits geschlossen).`, user) + } + } catch { + /* Ticket fehlt -> nur archivieren */ + } + } + return updated +} + +/** + * Aktiven Plan loeschen (destruktiv): alle Knoten + Roadmap entfernen, + * Auto-Ticket auf Cancelled setzen. Abgeschlossene Plaene werden nie geloescht. + */ +export async function deleteRoadmapPlan(roadmap, user) { + const nodes = await fetchRoadmapNodes(roadmap.$id) + await logRoadmapWorksheet(roadmap, `Roadmap "${roadmap.title}" geloescht. Ticket wird abgebrochen.`, user) + for (const node of nodes) { + await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id) + } + await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id) + if (roadmap.ticketId) { + try { + const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId) + if (ticket.status !== 'Closed' && ticket.status !== 'Cancelled') { + await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Cancelled' }) + } + } catch { + /* Ticket fehlt -> nichts zu tun */ + } + } +}