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
|
||||
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'
|
||||
]
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebpageProjectPanel ticket={ticket} />
|
||||
|
||||
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
||||
<div style={{
|
||||
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 = {
|
||||
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'
|
||||
],
|
||||
|
||||
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'
|
||||
|
||||
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()
|
||||
|
||||
@@ -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). */
|
||||
|
||||
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 [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() {
|
||||
</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">
|
||||
{/* Ticket Types */}
|
||||
<div className="col col-6">
|
||||
@@ -308,10 +343,31 @@ export default function AdminPage() {
|
||||
</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-header">
|
||||
<h3>Customers</h3>
|
||||
<h3>Kunden</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{customersLoading ? (
|
||||
@@ -537,8 +593,9 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Employees */}
|
||||
{adminSection === 'employees' && (
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Mitarbeiter & Kürzel</h3>
|
||||
@@ -673,25 +730,8 @@ export default function AdminPage() {
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Projects</h2>
|
||||
<h2>Website-Projekte</h2>
|
||||
</header>
|
||||
|
||||
<div className="text-center mb-2">
|
||||
<button className="btn btn-teal">
|
||||
<FaPlus /> New Project
|
||||
<div className="text-center mb-2" style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
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>
|
||||
</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">
|
||||
<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 className="projects-grid">
|
||||
{projects.map(project => (
|
||||
<div key={project.$id} className="project-card">
|
||||
<h4>{project.name}</h4>
|
||||
<p className="text-grey">{project.description}</p>
|
||||
<div className="project-meta">
|
||||
<span>{project.ticketCount || 0} tickets</span>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table" style={{ width: '100%', fontSize: '14px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Subdomain</th>
|
||||
<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>
|
||||
))}
|
||||
)}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user