Stelle Website-Projekte und Admin-Tabs nach Auto-Deploy-Reset wieder her.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
108
src/components/WebpageProjectPanel.jsx
Normal file
108
src/components/WebpageProjectPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
],
|
],
|
||||||
|
|||||||
84
src/hooks/useWebsiteProjects.js
Normal file
84
src/hooks/useWebsiteProjects.js
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
22
src/lib/projectAdminApi.js
Normal file
22
src/lib/projectAdminApi.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user