From fda673702e2254d5c019667b18b17adb7e97d005 Mon Sep 17 00:00:00 2001 From: Webklar Deploy Date: Mon, 25 May 2026 06:41:35 +0000 Subject: [PATCH] Stelle Website-Projekte und Admin-Tabs nach Auto-Deploy-Reset wieder her. Co-authored-by: Cursor --- src/components/CreateTicketModal.jsx | 2 +- src/components/TicketRow.jsx | 3 + src/components/WebpageProjectPanel.jsx | 108 ++++++++++++ src/hooks/useAdminConfig.js | 2 +- src/hooks/useWebsiteProjects.js | 84 +++++++++ src/hooks/useWorkorders.js | 83 +++++---- src/lib/appwrite.js | 39 +---- src/lib/projectAdminApi.js | 22 +++ src/pages/AdminPage.jsx | 82 ++++++--- src/pages/ProjectsPage.jsx | 228 +++++++++++++++++++++++-- 10 files changed, 543 insertions(+), 110 deletions(-) create mode 100644 src/components/WebpageProjectPanel.jsx create mode 100644 src/hooks/useWebsiteProjects.js create mode 100644 src/lib/projectAdminApi.js diff --git a/src/components/CreateTicketModal.jsx b/src/components/CreateTicketModal.jsx index 7fd2888..b093183 100644 --- a/src/components/CreateTicketModal.jsx +++ b/src/components/CreateTicketModal.jsx @@ -6,7 +6,7 @@ import { useEmployees } from '../hooks/useEmployees' // Fallback-Werte falls Config nicht geladen werden kann const DEFAULT_TICKET_TYPES = [ 'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request', - 'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation', + 'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation', 'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement', 'Rollout', 'Emergency Call', 'Other Services' ] diff --git a/src/components/TicketRow.jsx b/src/components/TicketRow.jsx index 8cc8e98..08abcd8 100644 --- a/src/components/TicketRow.jsx +++ b/src/components/TicketRow.jsx @@ -10,6 +10,7 @@ import CreateWorksheetModal from './CreateWorksheetModal' import StatusHistoryModal from './StatusHistoryModal' import WorksheetList from './WorksheetList' import WorksheetStats from './WorksheetStats' +import WebpageProjectPanel from './WebpageProjectPanel' import { useWorksheets } from '../hooks/useWorksheets' const PRIORITY_CLASSES = { @@ -316,6 +317,8 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) { + + {/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
{ + if (!ticket?.$id || ticket.type !== WEBPAGE_TICKET_TYPE) return + setLoading(true) + setError('') + try { + const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)]) + setAssigned(mine) + const ids = new Set(mine.map((p) => p.$id)) + setAvailable(getAvailableProjects(all, ticket.customerId).filter((p) => !ids.has(p.$id))) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, [ticket, fetchAllProjects, fetchByTicketId, getAvailableProjects]) + + useEffect(() => { loadProjects() }, [loadProjects]) + if (ticket?.type !== WEBPAGE_TICKET_TYPE) return null + + const handleAssign = async () => { + if (!selectedIds.length || !ticket.customerId) return + setActionLoading(true) + const r = await assignProjects(selectedIds, { customerId: ticket.customerId, ticketId: ticket.$id }) + setActionLoading(false) + if (r.success) { setSelectedIds([]); await loadProjects() } else setError(r.error) + } + + const handleCreate = async (e) => { + e.preventDefault() + if (!ticket.customerId) { setError('Kein Kunde am Ticket.'); return } + setActionLoading(true) + setError('') + try { + await createProjectFromTemplate({ + repoName: createForm.repoName || createForm.subdomain, + displayName: createForm.displayName, + subdomain: slugify(createForm.subdomain), + customerId: ticket.customerId, + ticketId: ticket.$id, + }) + setCreateForm({ displayName: '', subdomain: '', repoName: '' }) + await loadProjects() + } catch (err) { + setError(err.message) + } finally { + setActionLoading(false) + } + } + + return ( +
+
Website-Projekte
+ {error &&
{error}
} + {loading ?

Laden...

: ( + <> +
+
Zugewiesen
+ {assigned.length === 0 ?

Keine Projekte.

: assigned.map((p) => ( +
+ {p.projectName} ({p.subdomain}) + {p.previewUrl && } + +
+ ))} +
+
+
Verfügbar zuweisen
+ {available.map((p) => ( + + ))} + +
+
+
Neues Projekt
+
+ setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required /> + setCreateForm((f) => ({ ...f, subdomain: e.target.value, repoName: f.repoName || slugify(e.target.value) }))} required /> + +
+
+ + )} +
+ ) +} diff --git a/src/hooks/useAdminConfig.js b/src/hooks/useAdminConfig.js index e3fe089..3d8dbb0 100644 --- a/src/hooks/useAdminConfig.js +++ b/src/hooks/useAdminConfig.js @@ -7,7 +7,7 @@ const DEMO_MODE = isDemoMode const DEFAULT_CONFIG = { ticketTypes: [ 'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request', - 'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation', + 'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation', 'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement', 'Rollout', 'Emergency Call', 'Other Services' ], diff --git a/src/hooks/useWebsiteProjects.js b/src/hooks/useWebsiteProjects.js new file mode 100644 index 0000000..ffe1152 --- /dev/null +++ b/src/hooks/useWebsiteProjects.js @@ -0,0 +1,84 @@ +import { useState, useCallback } from 'react' +import { databases, DATABASE_ID, COLLECTIONS, Query, PREVIEW_TEMPLATE_NAME, isDemoMode } from '../lib/appwrite' + +function isTemplateProject(project) { + const template = project.templateName || PREVIEW_TEMPLATE_NAME + return template === PREVIEW_TEMPLATE_NAME || Boolean(project.repoFullName) +} + +export function useWebsiteProjects() { + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchAllProjects = useCallback(async () => { + if (isDemoMode) return [] + setLoading(true) + setError(null) + try { + const response = await databases.listDocuments( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, + [Query.orderDesc('$createdAt'), Query.limit(500)] + ) + setProjects(response.documents) + return response.documents + } catch (err) { + setError(err.message) + return [] + } finally { + setLoading(false) + } + }, []) + + const fetchByTicketId = useCallback(async (ticketId) => { + if (!ticketId || isDemoMode) return [] + try { + const response = await databases.listDocuments( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, + [Query.equal('ticketId', ticketId), Query.limit(100)] + ) + return response.documents + } catch { + return [] + } + }, []) + + const getAvailableProjects = useCallback((allProjects, customerId) => { + return (allProjects || []).filter(isTemplateProject).filter((p) => { + if (!p.customerId) return true + return customerId && p.customerId === customerId + }) + }, []) + + const assignProject = async (projectId, { customerId, ticketId }) => { + try { + const response = await databases.updateDocument( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId, + { customerId, ticketId, updatedAt: new Date().toISOString() } + ) + setProjects((prev) => prev.map((p) => (p.$id === projectId ? response : p))) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + const assignProjects = async (projectIds, ctx) => { + const results = await Promise.all(projectIds.map((id) => assignProject(id, ctx))) + return results.find((r) => !r.success) || { success: true } + } + + const unassignProject = async (projectId) => { + try { + await databases.updateDocument( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId, + { customerId: '', ticketId: '', updatedAt: new Date().toISOString() } + ) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + return { projects, loading, error, fetchAllProjects, fetchByTicketId, getAvailableProjects, assignProjects, unassignProject } +} diff --git a/src/hooks/useWorkorders.js b/src/hooks/useWorkorders.js index c7e9b63..c319198 100644 --- a/src/hooks/useWorkorders.js +++ b/src/hooks/useWorkorders.js @@ -1,8 +1,19 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite' const DEMO_MODE = isDemoMode +function buildFiltersKey(filters = {}) { + return JSON.stringify({ + limit: filters.limit ?? null, + customerId: filters.customerId ?? null, + assignedTo: filters.assignedTo ?? null, + status: filters.status ?? null, + type: filters.type ?? null, + priority: filters.priority ?? null, + }) +} + // Demo data for testing without Appwrite const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) @@ -69,21 +80,30 @@ export function useWorkorders(filters = {}) { const [workorders, setWorkorders] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const filtersKey = useMemo(() => buildFiltersKey(filters), [ + filters.limit, + filters.customerId, + filters.assignedTo, + filters.status, + filters.type, + filters.priority, + ]) const fetchWorkorders = useCallback(async () => { + const activeFilters = JSON.parse(filtersKey) setLoading(true) if (DEMO_MODE) { // Filter demo data let filtered = [...DEMO_WORKORDERS] - if (filters.status?.length > 0) { - filtered = filtered.filter(wo => filters.status.includes(wo.status)) + if (activeFilters.status?.length > 0) { + filtered = filtered.filter(wo => activeFilters.status.includes(wo.status)) } - if (filters.priority?.length > 0) { - filtered = filtered.filter(wo => filters.priority.includes(wo.priority)) + if (activeFilters.priority?.length > 0) { + filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority)) } - if (filters.limit) { - filtered = filtered.slice(0, filters.limit) + if (activeFilters.limit) { + filtered = filtered.slice(0, activeFilters.limit) } setWorkorders(filtered) setLoading(false) @@ -93,39 +113,34 @@ export function useWorkorders(filters = {}) { try { const queries = [Query.orderDesc('$createdAt')] - if (filters.limit) { - queries.push(Query.limit(filters.limit)) + if (activeFilters.limit) { + queries.push(Query.limit(activeFilters.limit)) } - // Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or() - // Wir filtern clientseitig für mehrere Werte - if (filters.status && filters.status.length > 0) { - if (filters.status.length === 1) { - queries.push(Query.equal('status', filters.status[0])) + if (activeFilters.status && activeFilters.status.length > 0) { + if (activeFilters.status.length === 1) { + queries.push(Query.equal('status', activeFilters.status[0])) } - // Für mehrere Werte: Clientseitig filtern (siehe unten) } - if (filters.type && filters.type.length > 0) { - if (filters.type.length === 1) { - queries.push(Query.equal('type', filters.type[0])) + if (activeFilters.type && activeFilters.type.length > 0) { + if (activeFilters.type.length === 1) { + queries.push(Query.equal('type', activeFilters.type[0])) } - // Für mehrere Werte: Clientseitig filtern } - if (filters.priority && filters.priority.length > 0) { - if (filters.priority.length === 1) { - queries.push(Query.equal('priority', filters.priority[0])) + if (activeFilters.priority && activeFilters.priority.length > 0) { + if (activeFilters.priority.length === 1) { + queries.push(Query.equal('priority', activeFilters.priority[0])) } - // Für mehrere Werte: Clientseitig filtern } - if (filters.customerId) { - queries.push(Query.equal('customerId', filters.customerId)) + if (activeFilters.customerId) { + queries.push(Query.equal('customerId', activeFilters.customerId)) } - if (filters.assignedTo) { - queries.push(Query.equal('assignedTo', filters.assignedTo)) + if (activeFilters.assignedTo) { + queries.push(Query.equal('assignedTo', activeFilters.assignedTo)) } // Debug: Zeige Collection ID @@ -145,16 +160,16 @@ export function useWorkorders(filters = {}) { // Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist) let filteredDocs = response.documents - if (filters.status && filters.status.length > 1) { - filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status)) + if (activeFilters.status && activeFilters.status.length > 1) { + filteredDocs = filteredDocs.filter(doc => activeFilters.status.includes(doc.status)) } - if (filters.type && filters.type.length > 1) { - filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type)) + if (activeFilters.type && activeFilters.type.length > 1) { + filteredDocs = filteredDocs.filter(doc => activeFilters.type.includes(doc.type)) } - if (filters.priority && filters.priority.length > 1) { - filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority)) + if (activeFilters.priority && activeFilters.priority.length > 1) { + filteredDocs = filteredDocs.filter(doc => activeFilters.priority.includes(doc.priority)) } setWorkorders(filteredDocs) @@ -174,7 +189,7 @@ export function useWorkorders(filters = {}) { } finally { setLoading(false) } - }, [filters]) + }, [filtersKey]) useEffect(() => { fetchWorkorders() diff --git a/src/lib/appwrite.js b/src/lib/appwrite.js index b588926..5174d89 100644 --- a/src/lib/appwrite.js +++ b/src/lib/appwrite.js @@ -11,41 +11,6 @@ export const projectId = ( /** Demo-Modus nur wenn wirklich keine Projekt-ID (sollte mit Defaults nie passieren) */ export const isDemoMode = !projectId -// #region agent log -if (typeof window !== 'undefined') { - const _dbg = { - sessionId: '252827', - runId: 'pre-fix', - hypothesisId: 'A', - location: 'appwrite.js:init', - message: 'Appwrite env at runtime', - data: { - mode: import.meta.env.MODE, - isDemoMode, - projectIdPrefix: projectId ? projectId.slice(0, 8) : '', - fromEnv: Boolean((import.meta.env.VITE_APPWRITE_PROJECT_ID || '').trim()), - endpointHost: (() => { - try { - return new URL(endpoint).host - } catch { - return 'invalid' - } - })(), - pageHost: window.location.host, - }, - timestamp: Date.now(), - } - fetch('http://127.0.0.1:7284/ingest/0747da40-b90b-4354-9b84-c9b550a81ec9', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '252827' }, - body: JSON.stringify(_dbg), - }).catch(() => {}) - if (import.meta.env.DEV) { - console.info('[DEBUG 252827]', _dbg) - } -} -// #endregion - if (import.meta.env.DEV) { console.log('🔧 Appwrite Konfiguration:') console.log('Endpoint:', endpoint) @@ -74,8 +39,12 @@ export const COLLECTIONS = { WORKSHEETS: 'worksheets', USERS: 'users', ATTACHMENTS: 'attachments', + WEBSITE_PROJECTS: 'websiteProjects', } +export const WEBPAGE_TICKET_TYPE = 'Webpage' +export const PREVIEW_TEMPLATE_NAME = 'webklar-preview-template' + export const BUCKET_ID = import.meta.env.VITE_APPWRITE_BUCKET_ID || 'woms-attachments' /** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */ diff --git a/src/lib/projectAdminApi.js b/src/lib/projectAdminApi.js new file mode 100644 index 0000000..45a0af4 --- /dev/null +++ b/src/lib/projectAdminApi.js @@ -0,0 +1,22 @@ +import { account } from './appwrite' + +const PROJECT_ADMIN_URL = + import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' + +export async function createProjectFromTemplate(payload) { + const jwt = await account.createJWT() + const response = await fetch( + `${PROJECT_ADMIN_URL}/api/admin/website-projects/create-from-template`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || `API-Fehler ${response.status}`) + return data +} diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index ffb9b61..515aaae 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -30,6 +30,7 @@ export default function AdminPage() { const [customerForm, setCustomerForm] = useState({ code: '', name: '', location: '', email: '', phone: '' }) const [editingEmployee, setEditingEmployee] = useState(null) const [employeeForm, setEmployeeForm] = useState({ userId: '', displayName: '', email: '', shortcode: '' }) + const [adminSection, setAdminSection] = useState('config') // Update localConfig when config loads useEffect(() => { @@ -122,6 +123,40 @@ export default function AdminPage() {
)} +
+ {[ + { id: 'config', label: 'Konfiguration' }, + { id: 'customers', label: 'Kunden' }, + { id: 'employees', label: 'Mitarbeiter' }, + ].map((section) => ( + + ))} +
+ + {adminSection === 'config' && ( + <>
{/* Ticket Types */}
@@ -308,10 +343,31 @@ export default function AdminPage() {
- {/* Customers */} +
+ +
+ + )} + + {adminSection === 'customers' && (
-

Customers

+

Kunden

{customersLoading ? ( @@ -537,8 +593,9 @@ export default function AdminPage() { )}
+ )} - {/* Employees */} + {adminSection === 'employees' && (

Mitarbeiter & Kürzel

@@ -673,25 +730,8 @@ export default function AdminPage() { )}
+ )} -
- -
) } diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 6fbf293..0191f62 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -1,37 +1,229 @@ -import { useState } from 'react' -import { FaFolder, FaPlus } from 'react-icons/fa6' +import { useState, useEffect, useMemo } from 'react' +import { FaFolder, FaPlus, FaArrowUpRightFromSquare } from 'react-icons/fa6' +import { useWebsiteProjects } from '../hooks/useWebsiteProjects' +import { useCustomers } from '../hooks/useCustomers' +import { useWorkorders } from '../hooks/useWorkorders' +import { createProjectFromTemplate } from '../lib/projectAdminApi' + +function slugify(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100) +} + +const WORKORDER_FILTERS = { limit: 500 } export default function ProjectsPage() { - const [projects] = useState([]) + const { projects, loading, error, fetchAllProjects } = useWebsiteProjects() + const { customers } = useCustomers() + const { workorders } = useWorkorders(WORKORDER_FILTERS) + const [filter, setFilter] = useState('all') + const [showCreateModal, setShowCreateModal] = useState(false) + const [createForm, setCreateForm] = useState({ + displayName: '', + subdomain: '', + repoName: '', + customerId: '', + }) + const [createLoading, setCreateLoading] = useState(false) + const [createError, setCreateError] = useState('') + + useEffect(() => { + fetchAllProjects() + }, [fetchAllProjects]) + + const customerMap = useMemo(() => { + const map = {} + for (const c of customers) { + map[c.$id] = c.name || c.code || c.$id + } + return map + }, [customers]) + + const ticketMap = useMemo(() => { + const map = {} + for (const wo of workorders) { + map[wo.$id] = wo.woid || wo.$id + } + return map + }, [workorders]) + + const filteredProjects = useMemo(() => { + if (filter === 'unassigned') { + return projects.filter((p) => !p.customerId) + } + if (filter === 'assigned') { + return projects.filter((p) => Boolean(p.customerId)) + } + return projects + }, [projects, filter]) + + const handleCreate = async (e) => { + e.preventDefault() + setCreateLoading(true) + setCreateError('') + try { + await createProjectFromTemplate({ + repoName: createForm.repoName || createForm.subdomain, + displayName: createForm.displayName, + subdomain: slugify(createForm.subdomain), + customerId: createForm.customerId || '', + ticketId: '', + }) + setShowCreateModal(false) + setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' }) + await fetchAllProjects() + } catch (err) { + setCreateError(err.message || 'Projekt konnte nicht angelegt werden') + } finally { + setCreateLoading(false) + } + } return (
-

Projects

+

Website-Projekte

-
-
- {projects.length === 0 ? ( + {error && ( +
+ {error} +
+ )} + + {loading ? ( +

Projekte werden geladen...

+ ) : filteredProjects.length === 0 ? (
-

No projects yet. Create your first project to get started.

+

Keine Projekte gefunden.

) : ( -
- {projects.map(project => ( -
-

{project.name}

-

{project.description}

-
- {project.ticketCount || 0} tickets +
+ + + + + + + + + + + + + + {filteredProjects.map((project) => ( + + + + + + + + + + ))} + +
NameSubdomainTemplateKundeTicket (WOID)StatusPreview
{project.projectName}{project.subdomain}{project.templateName || '-'}{project.customerId ? customerMap[project.customerId] || project.customerId : '-'}{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}{project.status || '-'} + {project.previewUrl ? ( + + Link + + ) : ( + '-' + )} +
+
+ )} + + {showCreateModal && ( +
+ setShowCreateModal(false)}>× +
+

Neues Website-Projekt

+ {createError && ( +
+ {createError}
-
- ))} + )} +
+
+ + setCreateForm((p) => ({ ...p, displayName: e.target.value }))} + required + /> +
+
+ + + setCreateForm((p) => ({ + ...p, + subdomain: e.target.value, + repoName: p.repoName || slugify(e.target.value), + })) + } + required + /> +
+
+ + setCreateForm((p) => ({ ...p, repoName: e.target.value }))} + /> +
+
+ + +
+
+ +
+
+
)}