Gitea-Projektintegration im Ticket-Frontend dauerhaft einbinden.
Projekt-Zuweisung, README-Anzeige und Admin-API-Hooks ins Repo committen, damit deploy.sh die Änderungen nicht mehr verwirft. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1198
package-lock.json
generated
1198
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
|
|||||||
30
src/components/AssignedProjectCard.jsx
Normal file
30
src/components/AssignedProjectCard.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { FaTrash, FaCodeBranch } from 'react-icons/fa'
|
||||||
|
import ProjectReadme from './ProjectReadme'
|
||||||
|
import GiteaLinkButton from './GiteaLinkButton'
|
||||||
|
import PreviewLinkButton from './PreviewLinkButton'
|
||||||
|
|
||||||
|
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<strong>{project.projectName}</strong>
|
||||||
|
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
||||||
|
{project.repoFullName && (
|
||||||
|
<div className="text-grey" style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||||
|
<PreviewLinkButton href={project.previewUrl} />
|
||||||
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
|
{showActions && onUnassign && (
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => onUnassign(project.$id)}><FaTrash /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/components/GiteaLinkButton.jsx
Normal file
30
src/components/GiteaLinkButton.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SiGitea } from 'react-icons/si'
|
||||||
|
|
||||||
|
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
|
||||||
|
if (!href) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.35)',
|
||||||
|
color: '#818cf8',
|
||||||
|
textDecoration: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SiGitea size={size} />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/components/PreviewLinkButton.jsx
Normal file
30
src/components/PreviewLinkButton.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { FaEye } from 'react-icons/fa6'
|
||||||
|
|
||||||
|
export default function PreviewLinkButton({ href, title = 'Preview oeffnen', size = 16 }) {
|
||||||
|
if (!href) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.35)',
|
||||||
|
color: '#34d399',
|
||||||
|
textDecoration: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEye size={size} />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/components/ProjectReadme.jsx
Normal file
50
src/components/ProjectReadme.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { FaFileLines } from 'react-icons/fa6'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import { fetchProjectReadme } from '../lib/projectAdminApi'
|
||||||
|
|
||||||
|
const markdownComponents = {
|
||||||
|
h1: ({ children }) => <h1 style={{ fontSize: '1.35rem', fontWeight: 700, margin: '0 0 10px', color: '#f8fafc' }}>{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 style={{ fontSize: '1.15rem', fontWeight: 700, margin: '14px 0 8px', color: '#f1f5f9' }}>{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '12px 0 6px', color: '#e2e8f0' }}>{children}</h3>,
|
||||||
|
p: ({ children }) => <p style={{ margin: '0 0 10px', lineHeight: 1.6 }}>{children}</p>,
|
||||||
|
ul: ({ children }) => <ul style={{ margin: '0 0 10px', paddingLeft: 20 }}>{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol style={{ margin: '0 0 10px', paddingLeft: 20 }}>{children}</ol>,
|
||||||
|
code: ({ inline, children }) => inline ? (
|
||||||
|
<code style={{ background: 'rgba(30,41,59,0.9)', padding: '2px 6px', borderRadius: 4, fontSize: '0.9em' }}>{children}</code>
|
||||||
|
) : <code>{children}</code>,
|
||||||
|
pre: ({ children }) => <pre style={{ background: 'rgba(15,23,42,0.95)', borderRadius: 6, padding: 10, overflowX: 'auto', margin: '0 0 10px' }}>{children}</pre>,
|
||||||
|
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>{children}</a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectReadme({ repoFullName }) {
|
||||||
|
const [state, setState] = useState({ loading: true, content: '', filename: '', error: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!repoFullName) { setState({ loading: false, content: '', filename: '', error: '' }); return }
|
||||||
|
let cancelled = false
|
||||||
|
setState({ loading: true, content: '', filename: '', error: '' })
|
||||||
|
fetchProjectReadme(repoFullName)
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (!data.found) { setState({ loading: false, content: '', filename: '', error: '' }); return }
|
||||||
|
setState({ loading: false, content: data.content || '', filename: data.filename || 'README.md', error: '' })
|
||||||
|
})
|
||||||
|
.catch((err) => { if (!cancelled) setState({ loading: false, content: '', filename: '', error: err.message }) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [repoFullName])
|
||||||
|
|
||||||
|
if (state.loading) return <p className="text-grey" style={{ fontSize: 13, marginTop: 8 }}>README wird geladen...</p>
|
||||||
|
if (state.error || !state.content) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 13, color: '#94a3b8' }}>
|
||||||
|
<FaFileLines /><span>{state.filename}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(148,163,184,0.2)', borderRadius: 8, padding: '12px 14px', fontSize: 13, color: '#e2e8f0', maxHeight: 320, overflowY: 'auto' }}>
|
||||||
|
<ReactMarkdown components={markdownComponents}>{state.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/TicketAssignedProjectsPanel.jsx
Normal file
34
src/components/TicketAssignedProjectsPanel.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
import AssignedProjectCard from './AssignedProjectCard'
|
||||||
|
|
||||||
|
export default function TicketAssignedProjectsPanel({ ticket, refreshKey = 0 }) {
|
||||||
|
const { fetchByTicketId } = useWebsiteProjects()
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadProjects = useCallback(async () => {
|
||||||
|
if (!ticket?.$id) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setProjects(await fetchByTicketId(ticket.$id))
|
||||||
|
} catch {
|
||||||
|
setProjects([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [ticket?.$id, fetchByTicketId])
|
||||||
|
|
||||||
|
useEffect(() => { loadProjects() }, [loadProjects, refreshKey])
|
||||||
|
|
||||||
|
if (loading || projects.length === 0) return null
|
||||||
|
|
||||||
|
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 }}>Zugewiesene Projekte ({projects.length})</h5>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<AssignedProjectCard key={project.$id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/components/TicketProjectsModal.jsx
Normal file
125
src/components/TicketProjectsModal.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FaPlus, FaTimes } from 'react-icons/fa'
|
||||||
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
||||||
|
import AssignedProjectCard from './AssignedProjectCard'
|
||||||
|
|
||||||
|
function slugify(v) {
|
||||||
|
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketProjectsModal({ isOpen, onClose, ticket, onUpdated }) {
|
||||||
|
const { fetchAllProjects, fetchByTicketId, 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) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
|
||||||
|
setAssigned(mine)
|
||||||
|
const assignedIds = new Set(mine.map((p) => p.$id))
|
||||||
|
setAvailable((all || []).filter((p) => p.repoFullName && !assignedIds.has(p.$id)))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [ticket, fetchAllProjects, fetchByTicketId])
|
||||||
|
|
||||||
|
useEffect(() => { if (isOpen) loadProjects() }, [isOpen, loadProjects])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleAssign = async () => {
|
||||||
|
if (!selectedIds.length) return
|
||||||
|
setActionLoading(true)
|
||||||
|
const r = await assignProjects(selectedIds, { customerId: ticket.customerId || '', ticketId: ticket.$id })
|
||||||
|
setActionLoading(false)
|
||||||
|
if (r.success) { setSelectedIds([]); await loadProjects(); onUpdated?.() } else setError(r.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
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()
|
||||||
|
onUpdated?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnassign = async (projectId) => {
|
||||||
|
setActionLoading(true)
|
||||||
|
await unassignProject(projectId)
|
||||||
|
setActionLoading(false)
|
||||||
|
await loadProjects()
|
||||||
|
onUpdated?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay ticket-projects-overlay">
|
||||||
|
<span className="overlay-close" onClick={onClose}>{'\u00d7'}</span>
|
||||||
|
<div className="overlay-content ticket-projects-modal">
|
||||||
|
<div className="ticket-projects-modal-header">
|
||||||
|
<h2>Projekte bearbeiten</h2>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={onClose} aria-label="Schliessen"><FaTimes /></button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="bg-red text-white p-2 mb-2">{error}</div>}
|
||||||
|
{loading ? <p className="text-grey">Projekte werden geladen...</p> : (
|
||||||
|
<div className="ticket-projects-modal-grid">
|
||||||
|
<section className="ticket-projects-modal-section">
|
||||||
|
<h6>Zugewiesen</h6>
|
||||||
|
{assigned.length === 0 ? <p className="text-grey">Keine Projekte zugewiesen.</p> : assigned.map((p) => (
|
||||||
|
<AssignedProjectCard key={p.$id} project={p} showActions onUnassign={handleUnassign} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<div className="ticket-projects-modal-side">
|
||||||
|
<section className="ticket-projects-modal-section">
|
||||||
|
<h6>Verfuegbar zuweisen</h6>
|
||||||
|
{available.length === 0 ? <p className="text-grey">Keine weiteren Projekte verfuegbar.</p> : (
|
||||||
|
<div className="ticket-projects-available-list">
|
||||||
|
{available.map((p) => (
|
||||||
|
<label key={p.$id} className="ticket-projects-available-item">
|
||||||
|
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
|
||||||
|
<span>{p.projectName} ({p.subdomain || p.repoFullName})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-teal mt-2" disabled={!selectedIds.length || actionLoading} onClick={handleAssign}>Zuweisen</button>
|
||||||
|
</section>
|
||||||
|
<section className="ticket-projects-modal-section">
|
||||||
|
<h6><FaPlus /> Neues Projekt</h6>
|
||||||
|
<form onSubmit={handleCreate} className="ticket-projects-create-form">
|
||||||
|
<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}>{actionLoading ? 'Wird angelegt...' : 'Anlegen & zuweisen'}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft } from 'react-icons/fa6'
|
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
|
||||||
import { formatDistanceToNow, format } from 'date-fns'
|
import { formatDistanceToNow, format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
import StatusDropdown from './StatusDropdown'
|
import StatusDropdown from './StatusDropdown'
|
||||||
@@ -10,7 +10,8 @@ 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 TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||||
|
import TicketProjectsModal from './TicketProjectsModal'
|
||||||
import { useWorksheets } from '../hooks/useWorksheets'
|
import { useWorksheets } from '../hooks/useWorksheets'
|
||||||
|
|
||||||
const PRIORITY_CLASSES = {
|
const PRIORITY_CLASSES = {
|
||||||
@@ -49,6 +50,8 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
const [locked, setLocked] = useState(true)
|
const [locked, setLocked] = useState(true)
|
||||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
|
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||||
|
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||||
|
|
||||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
||||||
const {
|
const {
|
||||||
@@ -268,6 +271,19 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: '#3b82f6', color: 'white', border: 'none', padding: '12px',
|
||||||
|
borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', minWidth: '44px', width: '44px',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowProjectsModal(true)}
|
||||||
|
title="Projekte bearbeiten"
|
||||||
|
>
|
||||||
|
<FaPen size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* History Icon Button - klein, grau, nur Icon */}
|
{/* History Icon Button - klein, grau, nur Icon */}
|
||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
@@ -317,7 +333,7 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WebpageProjectPanel ticket={ticket} />
|
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||||
|
|
||||||
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -350,6 +366,13 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
worksheets={worksheets}
|
worksheets={worksheets}
|
||||||
ticket={ticket}
|
ticket={ticket}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TicketProjectsModal
|
||||||
|
isOpen={showProjectsModal}
|
||||||
|
onClose={() => setShowProjectsModal(false)}
|
||||||
|
ticket={ticket}
|
||||||
|
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
<tr className="spacer">
|
<tr className="spacer">
|
||||||
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { FaExternalLinkAlt, FaPlus, FaTrash } from 'react-icons/fa'
|
import { FaPlus, FaTrash } from 'react-icons/fa'
|
||||||
|
import PreviewLinkButton from './PreviewLinkButton'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
||||||
import { WEBPAGE_TICKET_TYPE } from '../lib/appwrite'
|
import { WEBPAGE_TICKET_TYPE } from '../lib/appwrite'
|
||||||
@@ -78,13 +79,13 @@ export default function WebpageProjectPanel({ ticket }) {
|
|||||||
{assigned.length === 0 ? <p className="text-grey">Keine Projekte.</p> : assigned.map((p) => (
|
{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 }}>
|
<div key={p.$id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
|
||||||
<span>{p.projectName} ({p.subdomain})</span>
|
<span>{p.projectName} ({p.subdomain})</span>
|
||||||
{p.previewUrl && <a href={p.previewUrl} target="_blank" rel="noreferrer"><FaExternalLinkAlt /></a>}
|
<PreviewLinkButton href={p.previewUrl} />
|
||||||
<button type="button" className="btn btn-sm" onClick={() => unassignProject(p.$id).then(loadProjects)}><FaTrash /></button>
|
<button type="button" className="btn btn-sm" onClick={() => unassignProject(p.$id).then(loadProjects)}><FaTrash /></button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<section style={{ marginBottom: 16 }}>
|
<section style={{ marginBottom: 16 }}>
|
||||||
<h6>Verf<EFBFBD>gbar zuweisen</h6>
|
<h6>Verf<EFBFBD>gbar zuweisen</h6>
|
||||||
{available.map((p) => (
|
{available.map((p) => (
|
||||||
<label key={p.$id} style={{ display: 'block' }}>
|
<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])} />
|
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
|
||||||
|
|||||||
@@ -49,13 +49,14 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{worksheets.map((ws, index) => {
|
{worksheets.map((ws, index) => {
|
||||||
const isExpanded = expandedWorksheets[ws.wsid] || false
|
const isExpanded = expandedWorksheets[ws.wsid] || false
|
||||||
|
const isGit = ws.serviceType === 'GIT'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={ws.$id} className="timeline-item mb-4" style={{
|
<div key={ws.$id} className="timeline-item mb-4" style={{
|
||||||
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
||||||
}}>
|
}}>
|
||||||
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
||||||
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
borderLeft: isGit ? '4px solid #f97316' : ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
}} onMouseEnter={(e) => {
|
}} onMouseEnter={(e) => {
|
||||||
@@ -70,7 +71,9 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
className="card-header d-flex justify-content-between align-items-center py-3"
|
className="card-header d-flex justify-content-between align-items-center py-3"
|
||||||
onClick={() => toggleWorksheet(ws.wsid)}
|
onClick={() => toggleWorksheet(ws.wsid)}
|
||||||
style={{
|
style={{
|
||||||
background: ws.isComment
|
background: isGit
|
||||||
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
: ws.isComment
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
@@ -80,12 +83,16 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
transition: 'background 0.2s ease'
|
transition: 'background 0.2s ease'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = ws.isComment
|
e.currentTarget.style.background = isGit
|
||||||
|
? 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)'
|
||||||
|
: ws.isComment
|
||||||
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||||
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = ws.isComment
|
e.currentTarget.style.background = isGit
|
||||||
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
: ws.isComment
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,20 +3,35 @@ import { account } from './appwrite'
|
|||||||
const PROJECT_ADMIN_URL =
|
const PROJECT_ADMIN_URL =
|
||||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
|
|
||||||
export async function createProjectFromTemplate(payload) {
|
async function adminFetch(path, options = {}) {
|
||||||
const jwt = await account.createJWT()
|
const jwt = await account.createJWT()
|
||||||
const response = await fetch(
|
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
|
||||||
`${PROJECT_ADMIN_URL}/api/admin/website-projects/create-from-template`,
|
...options,
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${jwt.jwt}`,
|
Authorization: `Bearer ${jwt.jwt}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) throw new Error(data.error || `API-Fehler ${response.status}`)
|
if (!response.ok) throw new Error(data.error || `API-Fehler ${response.status}`)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createProjectFromTemplate(payload) {
|
||||||
|
return adminFetch('/api/admin/website-projects/create-from-template', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGiteaRepos() {
|
||||||
|
return adminFetch('/api/admin/gitea/sync-repos', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectReadme(repoFullName) {
|
||||||
|
if (!repoFullName) return { found: false }
|
||||||
|
return adminFetch(
|
||||||
|
`/api/admin/gitea/repo-readme?repoFullName=${encodeURIComponent(repoFullName)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { FaFolder, FaPlus, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
import { FaFolder, FaPlus } from 'react-icons/fa6'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useWorkorders } from '../hooks/useWorkorders'
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
||||||
|
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||||
|
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||||
|
|
||||||
function slugify(value) {
|
function slugify(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
@@ -128,7 +130,7 @@ export default function ProjectsPage() {
|
|||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>Ticket (WOID)</th>
|
<th>Ticket (WOID)</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Preview</th>
|
<th>Links</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -141,13 +143,10 @@ export default function ProjectsPage() {
|
|||||||
<td>{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}</td>
|
<td>{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}</td>
|
||||||
<td>{project.status || '-'}</td>
|
<td>{project.status || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{project.previewUrl ? (
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<a href={project.previewUrl} target="_blank" rel="noopener noreferrer">
|
<PreviewLinkButton href={project.previewUrl} />
|
||||||
<FaArrowUpRightFromSquare /> Link
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
</a>
|
</div>
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -531,6 +531,109 @@ textarea.form-control {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ticket-projects-overlay { padding: 24px; }
|
||||||
|
.ticket-projects-modal { width: 100%; max-width: none; min-height: calc(100vh - 48px); box-sizing: border-box; }
|
||||||
|
.ticket-projects-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.ticket-projects-modal-header h2 { margin: 0; }
|
||||||
|
.ticket-projects-modal-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
|
||||||
|
.ticket-projects-modal-section { background: rgba(45,55,72,0.65); border: 1px solid rgba(59,130,246,0.25); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; }
|
||||||
|
.ticket-projects-modal-section h6 { font-weight: bold; margin: 0 0 12px; }
|
||||||
|
.ticket-projects-modal-side { display: flex; flex-direction: column; }
|
||||||
|
.ticket-projects-available-list { max-height: min(50vh, 520px); overflow-y: auto; margin-bottom: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 16px; }
|
||||||
|
.ticket-projects-available-item { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; padding: 4px 0; }
|
||||||
|
.ticket-projects-create-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ticket-projects-modal-grid { grid-template-columns: 1fr; }
|
||||||
|
.ticket-projects-create-form { grid-template-columns: 1fr; }
|
||||||
|
.ticket-projects-available-list { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-overlay {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-section {
|
||||||
|
background: rgba(45, 55, 72, 0.65);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-section h6 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-available-list {
|
||||||
|
max-height: min(50vh, 520px);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-available-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-create-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ticket-projects-modal-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-create-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-available-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: rgba(45, 55, 72, 0.98) !important;
|
background: rgba(45, 55, 72, 0.98) !important;
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user