Stelle Website-Projekte und Admin-Tabs nach Auto-Deploy-Reset wieder her.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Webklar Deploy
2026-05-25 06:41:35 +00:00
parent 8d62e353cb
commit fda673702e
10 changed files with 543 additions and 110 deletions

View File

@@ -6,7 +6,7 @@ import { useEmployees } from '../hooks/useEmployees'
// Fallback-Werte falls Config nicht geladen werden kann // Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [ const DEFAULT_TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request', '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', 'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services' 'Rollout', 'Emergency Call', 'Other Services'
] ]

View File

@@ -10,6 +10,7 @@ import CreateWorksheetModal from './CreateWorksheetModal'
import StatusHistoryModal from './StatusHistoryModal' import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList' import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats' import WorksheetStats from './WorksheetStats'
import WebpageProjectPanel from './WebpageProjectPanel'
import { useWorksheets } from '../hooks/useWorksheets' import { useWorksheets } from '../hooks/useWorksheets'
const PRIORITY_CLASSES = { const PRIORITY_CLASSES = {
@@ -316,6 +317,8 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
</div> </div>
</div> </div>
<WebpageProjectPanel ticket={ticket} />
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */} {/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
<div style={{ <div style={{
marginTop: '20px', marginTop: '20px',

View File

@@ -0,0 +1,108 @@
import { useState, useEffect, useCallback } from 'react'
import { FaExternalLinkAlt, FaPlus, FaTrash } from 'react-icons/fa'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import { WEBPAGE_TICKET_TYPE } from '../lib/appwrite'
function slugify(v) {
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
}
export default function WebpageProjectPanel({ ticket }) {
const { fetchAllProjects, fetchByTicketId, getAvailableProjects, assignProjects, unassignProject } = useWebsiteProjects()
const [assigned, setAssigned] = useState([])
const [available, setAvailable] = useState([])
const [selectedIds, setSelectedIds] = useState([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState('')
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '', repoName: '' })
const loadProjects = useCallback(async () => {
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 (
<div style={{ background: 'rgba(45,55,72,0.5)', borderRadius: 12, padding: 20, border: '1px solid rgba(59,130,246,0.3)', marginTop: 20 }}>
<h5 style={{ fontWeight: 'bold', marginBottom: 12 }}>Website-Projekte</h5>
{error && <div className="bg-red text-white p-2 mb-2">{error}</div>}
{loading ? <p className="text-grey">Laden...</p> : (
<>
<section style={{ marginBottom: 16 }}>
<h6>Zugewiesen</h6>
{assigned.length === 0 ? <p className="text-grey">Keine Projekte.</p> : assigned.map((p) => (
<div key={p.$id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<span>{p.projectName} ({p.subdomain})</span>
{p.previewUrl && <a href={p.previewUrl} target="_blank" rel="noreferrer"><FaExternalLinkAlt /></a>}
<button type="button" className="btn btn-sm" onClick={() => unassignProject(p.$id).then(loadProjects)}><FaTrash /></button>
</div>
))}
</section>
<section style={{ marginBottom: 16 }}>
<h6>Verf<EFBFBD>gbar zuweisen</h6>
{available.map((p) => (
<label key={p.$id} style={{ display: 'block' }}>
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
{' '}{p.projectName} ({p.subdomain})
</label>
))}
<button type="button" className="btn btn-teal mt-2" disabled={!selectedIds.length || actionLoading} onClick={handleAssign}>Zuweisen</button>
</section>
<section>
<h6><FaPlus /> Neues Projekt</h6>
<form onSubmit={handleCreate} style={{ display: 'grid', gap: 8, maxWidth: 400 }}>
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value, repoName: f.repoName || slugify(e.target.value) }))} required />
<button type="submit" className="btn btn-dark" disabled={actionLoading}>Anlegen & zuweisen</button>
</form>
</section>
</>
)}
</div>
)
}

View File

@@ -7,7 +7,7 @@ const DEMO_MODE = isDemoMode
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
ticketTypes: [ ticketTypes: [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request', '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', 'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services' 'Rollout', 'Emergency Call', 'Other Services'
], ],

View File

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

View File

@@ -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' import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
const DEMO_MODE = isDemoMode 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 // Demo data for testing without Appwrite
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
@@ -69,21 +80,30 @@ export function useWorkorders(filters = {}) {
const [workorders, setWorkorders] = useState([]) const [workorders, setWorkorders] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) 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 fetchWorkorders = useCallback(async () => {
const activeFilters = JSON.parse(filtersKey)
setLoading(true) setLoading(true)
if (DEMO_MODE) { if (DEMO_MODE) {
// Filter demo data // Filter demo data
let filtered = [...DEMO_WORKORDERS] let filtered = [...DEMO_WORKORDERS]
if (filters.status?.length > 0) { if (activeFilters.status?.length > 0) {
filtered = filtered.filter(wo => filters.status.includes(wo.status)) filtered = filtered.filter(wo => activeFilters.status.includes(wo.status))
} }
if (filters.priority?.length > 0) { if (activeFilters.priority?.length > 0) {
filtered = filtered.filter(wo => filters.priority.includes(wo.priority)) filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
} }
if (filters.limit) { if (activeFilters.limit) {
filtered = filtered.slice(0, filters.limit) filtered = filtered.slice(0, activeFilters.limit)
} }
setWorkorders(filtered) setWorkorders(filtered)
setLoading(false) setLoading(false)
@@ -93,39 +113,34 @@ export function useWorkorders(filters = {}) {
try { try {
const queries = [Query.orderDesc('$createdAt')] const queries = [Query.orderDesc('$createdAt')]
if (filters.limit) { if (activeFilters.limit) {
queries.push(Query.limit(filters.limit)) queries.push(Query.limit(activeFilters.limit))
} }
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or() if (activeFilters.status && activeFilters.status.length > 0) {
// Wir filtern clientseitig für mehrere Werte if (activeFilters.status.length === 1) {
if (filters.status && filters.status.length > 0) { queries.push(Query.equal('status', activeFilters.status[0]))
if (filters.status.length === 1) {
queries.push(Query.equal('status', filters.status[0]))
} }
// Für mehrere Werte: Clientseitig filtern (siehe unten)
} }
if (filters.type && filters.type.length > 0) { if (activeFilters.type && activeFilters.type.length > 0) {
if (filters.type.length === 1) { if (activeFilters.type.length === 1) {
queries.push(Query.equal('type', filters.type[0])) queries.push(Query.equal('type', activeFilters.type[0]))
} }
// Für mehrere Werte: Clientseitig filtern
} }
if (filters.priority && filters.priority.length > 0) { if (activeFilters.priority && activeFilters.priority.length > 0) {
if (filters.priority.length === 1) { if (activeFilters.priority.length === 1) {
queries.push(Query.equal('priority', filters.priority[0])) queries.push(Query.equal('priority', activeFilters.priority[0]))
} }
// Für mehrere Werte: Clientseitig filtern
} }
if (filters.customerId) { if (activeFilters.customerId) {
queries.push(Query.equal('customerId', filters.customerId)) queries.push(Query.equal('customerId', activeFilters.customerId))
} }
if (filters.assignedTo) { if (activeFilters.assignedTo) {
queries.push(Query.equal('assignedTo', filters.assignedTo)) queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
} }
// Debug: Zeige Collection ID // Debug: Zeige Collection ID
@@ -145,16 +160,16 @@ export function useWorkorders(filters = {}) {
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist) // Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
let filteredDocs = response.documents let filteredDocs = response.documents
if (filters.status && filters.status.length > 1) { if (activeFilters.status && activeFilters.status.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status)) filteredDocs = filteredDocs.filter(doc => activeFilters.status.includes(doc.status))
} }
if (filters.type && filters.type.length > 1) { if (activeFilters.type && activeFilters.type.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type)) filteredDocs = filteredDocs.filter(doc => activeFilters.type.includes(doc.type))
} }
if (filters.priority && filters.priority.length > 1) { if (activeFilters.priority && activeFilters.priority.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority)) filteredDocs = filteredDocs.filter(doc => activeFilters.priority.includes(doc.priority))
} }
setWorkorders(filteredDocs) setWorkorders(filteredDocs)
@@ -174,7 +189,7 @@ export function useWorkorders(filters = {}) {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [filters]) }, [filtersKey])
useEffect(() => { useEffect(() => {
fetchWorkorders() fetchWorkorders()

View File

@@ -11,41 +11,6 @@ export const projectId = (
/** Demo-Modus nur wenn wirklich keine Projekt-ID (sollte mit Defaults nie passieren) */ /** Demo-Modus nur wenn wirklich keine Projekt-ID (sollte mit Defaults nie passieren) */
export const isDemoMode = !projectId 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) { if (import.meta.env.DEV) {
console.log('🔧 Appwrite Konfiguration:') console.log('🔧 Appwrite Konfiguration:')
console.log('Endpoint:', endpoint) console.log('Endpoint:', endpoint)
@@ -74,8 +39,12 @@ export const COLLECTIONS = {
WORKSHEETS: 'worksheets', WORKSHEETS: 'worksheets',
USERS: 'users', USERS: 'users',
ATTACHMENTS: 'attachments', 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' 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). */ /** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */

View File

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

View File

@@ -30,6 +30,7 @@ export default function AdminPage() {
const [customerForm, setCustomerForm] = useState({ code: '', name: '', location: '', email: '', phone: '' }) const [customerForm, setCustomerForm] = useState({ code: '', name: '', location: '', email: '', phone: '' })
const [editingEmployee, setEditingEmployee] = useState(null) const [editingEmployee, setEditingEmployee] = useState(null)
const [employeeForm, setEmployeeForm] = useState({ userId: '', displayName: '', email: '', shortcode: '' }) const [employeeForm, setEmployeeForm] = useState({ userId: '', displayName: '', email: '', shortcode: '' })
const [adminSection, setAdminSection] = useState('config')
// Update localConfig when config loads // Update localConfig when config loads
useEffect(() => { useEffect(() => {
@@ -122,6 +123,40 @@ export default function AdminPage() {
</div> </div>
)} )}
<div
className="text-center mb-2"
style={{
display: 'flex',
gap: '20px',
justifyContent: 'center',
flexWrap: 'wrap',
padding: '12px',
background: 'rgba(45, 55, 72, 0.4)',
borderRadius: '8px',
}}
>
{[
{ id: 'config', label: 'Konfiguration' },
{ id: 'customers', label: 'Kunden' },
{ id: 'employees', label: 'Mitarbeiter' },
].map((section) => (
<label
key={section.id}
style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: adminSection === section.id ? 'bold' : 'normal' }}
>
<input
type="radio"
name="adminSection"
checked={adminSection === section.id}
onChange={() => setAdminSection(section.id)}
/>
{section.label}
</label>
))}
</div>
{adminSection === 'config' && (
<>
<div className="row"> <div className="row">
{/* Ticket Types */} {/* Ticket Types */}
<div className="col col-6"> <div className="col col-6">
@@ -308,10 +343,31 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{/* Customers */} <div className="text-center mt-2">
<button
className="btn btn-dark"
onClick={handleSave}
disabled={saving}
style={{ minWidth: '200px' }}
>
{saving ? (
<>
<FaSpinner className="spinner" /> Speichere...
</>
) : (
<>
<FaFloppyDisk /> Konfiguration speichern
</>
)}
</button>
</div>
</>
)}
{adminSection === 'customers' && (
<div className="card mb-2"> <div className="card mb-2">
<div className="card-header"> <div className="card-header">
<h3>Customers</h3> <h3>Kunden</h3>
</div> </div>
<div className="card-body"> <div className="card-body">
{customersLoading ? ( {customersLoading ? (
@@ -537,8 +593,9 @@ export default function AdminPage() {
)} )}
</div> </div>
</div> </div>
)}
{/* Employees */} {adminSection === 'employees' && (
<div className="card mb-2"> <div className="card mb-2">
<div className="card-header"> <div className="card-header">
<h3>Mitarbeiter & Kürzel</h3> <h3>Mitarbeiter & Kürzel</h3>
@@ -673,25 +730,8 @@ export default function AdminPage() {
)} )}
</div> </div>
</div> </div>
)}
<div className="text-center mt-2">
<button
className="btn btn-dark"
onClick={handleSave}
disabled={saving}
style={{ minWidth: '200px' }}
>
{saving ? (
<>
<FaSpinner className="spinner" /> Speichere...
</>
) : (
<>
<FaFloppyDisk /> Konfiguration speichern
</>
)}
</button>
</div>
</div> </div>
) )
} }

View File

@@ -1,37 +1,229 @@
import { useState } from 'react' import { useState, useEffect, useMemo } from 'react'
import { FaFolder, FaPlus } from 'react-icons/fa6' 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() { 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 ( return (
<div className="main-content"> <div className="main-content">
<header className="text-center mb-2"> <header className="text-center mb-2">
<h2>Projects</h2> <h2>Website-Projekte</h2>
</header> </header>
<div className="text-center mb-2"> <div className="text-center mb-2" style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-teal"> <select
<FaPlus /> New Project className="form-control"
style={{ width: 'auto', minWidth: '180px' }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">Alle Projekte</option>
<option value="unassigned">Unzugeordnet</option>
<option value="assigned">Zugeordnet</option>
</select>
<button type="button" className="btn btn-teal" onClick={() => setShowCreateModal(true)}>
<FaPlus /> Neues Projekt
</button> </button>
</div> </div>
{projects.length === 0 ? ( {error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
{loading ? (
<p className="text-center text-grey">Projekte werden geladen...</p>
) : filteredProjects.length === 0 ? (
<div className="text-center p-4"> <div className="text-center p-4">
<FaFolder size={64} className="text-grey" /> <FaFolder size={64} className="text-grey" />
<p className="text-grey mt-2">No projects yet. Create your first project to get started.</p> <p className="text-grey mt-2">Keine Projekte gefunden.</p>
</div> </div>
) : ( ) : (
<div className="projects-grid"> <div style={{ overflowX: 'auto' }}>
{projects.map(project => ( <table className="table" style={{ width: '100%', fontSize: '14px' }}>
<div key={project.$id} className="project-card"> <thead>
<h4>{project.name}</h4> <tr>
<p className="text-grey">{project.description}</p> <th>Name</th>
<div className="project-meta"> <th>Subdomain</th>
<span>{project.ticketCount || 0} tickets</span> <th>Template</th>
<th>Kunde</th>
<th>Ticket (WOID)</th>
<th>Status</th>
<th>Preview</th>
</tr>
</thead>
<tbody>
{filteredProjects.map((project) => (
<tr key={project.$id}>
<td>{project.projectName}</td>
<td>{project.subdomain}</td>
<td>{project.templateName || '-'}</td>
<td>{project.customerId ? customerMap[project.customerId] || project.customerId : '-'}</td>
<td>{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}</td>
<td>{project.status || '-'}</td>
<td>
{project.previewUrl ? (
<a href={project.previewUrl} target="_blank" rel="noopener noreferrer">
<FaArrowUpRightFromSquare /> Link
</a>
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showCreateModal && (
<div className="overlay">
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Website-Projekt</h2>
{createError && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{createError}
</div> </div>
</div> )}
))} <form onSubmit={handleCreate}>
<div className="form-group">
<label className="form-label">Anzeigename</label>
<input
type="text"
className="form-control"
value={createForm.displayName}
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
required
/>
</div>
<div className="form-group">
<label className="form-label">Subdomain</label>
<input
type="text"
className="form-control"
value={createForm.subdomain}
onChange={(e) =>
setCreateForm((p) => ({
...p,
subdomain: e.target.value,
repoName: p.repoName || slugify(e.target.value),
}))
}
required
/>
</div>
<div className="form-group">
<label className="form-label">Repo-Name (optional)</label>
<input
type="text"
className="form-control"
value={createForm.repoName}
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
/>
</div>
<div className="form-group">
<label className="form-label">Kunde (optional)</label>
<select
className="form-control"
value={createForm.customerId}
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</div>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={createLoading}>
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
</button>
</div>
</form>
</div>
</div> </div>
)} )}
</div> </div>