feat(roadmaps): Kernlogik + Appwrite-Konstanten für projektspezifische Pläne
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
292
src/lib/roadmaps.js
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user