feat(roadmaps): Kernlogik + Appwrite-Konstanten für projektspezifische Pläne

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-22 19:53:16 +00:00
parent 52713fb481
commit 5361800ca9
2 changed files with 295 additions and 0 deletions

View File

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

292
src/lib/roadmaps.js Normal file
View File

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