Projekte im Ticket als eine Liste (Paket E)
- Im Details-Reiter steht eine Projektliste mit Zuweisen, Lösen und "Neues Projekt"; der Knopf "Projekte zuweisen / bearbeiten", der Dialog und die zweite Projektliste sind weg. - Zuweisen bietet nur passende Projekte an: gleicher Kunde oder ohne Kunde, nicht archiviert, keine Vorlage. - Lösen nimmt ein Projekt nur noch vom Ticket. Bisher wurde dabei auch die Kundenzuordnung gelöscht und der Kunde sah seine Vorschau im Portal nicht mehr. - Projektkarten ohne README-Vorschau (kostete je Karte einen Abruf) und ohne nie befüllte Felder (Live-Domain, Commit-Box, "Preview: aktiv"); react-markdown entfällt damit (Lockfile mit Node 20 erzeugt). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
92fdfdc961
commit
50d237a932
11 files changed
+137
-1569
No files matched your search
Generated
+5
-1199
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,6 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FaTrash, FaCodeBranch } from 'react-icons/fa'
|
||||
import { FaGlobe, FaCodeCommit, FaClock } from 'react-icons/fa6'
|
||||
import ProjectReadme from './ProjectReadme'
|
||||
import { FaCodeBranch } from 'react-icons/fa'
|
||||
import { FaLinkSlash } from 'react-icons/fa6'
|
||||
import GiteaLinkButton from './GiteaLinkButton'
|
||||
import PreviewLinkButton from './PreviewLinkButton'
|
||||
|
||||
@@ -30,7 +29,6 @@ function StatusBadge({ label, value }) {
|
||||
}
|
||||
|
||||
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
|
||||
const lastPush = project.lastPushAt || project.giteaSyncedAt
|
||||
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' }}>
|
||||
@@ -42,20 +40,20 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
||||
</div>
|
||||
)}
|
||||
{project.liveDomain && (
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
<FaGlobe style={{ marginRight: 4, color: '#34d399' }} />
|
||||
<a href={`https://${project.liveDomain}`} target="_blank" rel="noopener noreferrer" style={{ color: '#34d399', textDecoration: 'none' }}>
|
||||
{project.liveDomain}
|
||||
</a>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
title="Vom Ticket lösen"
|
||||
aria-label="Vom Ticket lösen"
|
||||
onClick={() => onUnassign(project.$id)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'rgba(239, 68, 68, 0.12)', border: '1px solid rgba(239, 68, 68, 0.35)', color: '#f87171', cursor: 'pointer' }}
|
||||
>
|
||||
<FaLinkSlash size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,34 +62,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<StatusBadge label="Status" value={project.status} />
|
||||
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
||||
{project.hasPreview && <StatusBadge label="Preview" value="aktiv" />}
|
||||
</div>
|
||||
|
||||
{/* Letzter Commit */}
|
||||
{(project.lastCommitMessage || project.lastCommitSha) && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#94a3b8', background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: '8px 10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FaCodeCommit style={{ color: '#3b82f6' }} />
|
||||
{project.lastCommitSha && <code style={{ color: '#cbd5e0' }}>{String(project.lastCommitSha).slice(0, 7)}</code>}
|
||||
{project.lastCommitBranch || project.lastPushBranch ? (
|
||||
<span className="text-grey">({project.lastPushBranch || project.lastCommitBranch})</span>
|
||||
) : null}
|
||||
</div>
|
||||
{project.lastCommitMessage && (
|
||||
<div style={{ marginTop: 4, color: '#cbd5e0', whiteSpace: 'pre-wrap' }}>{project.lastCommitMessage}</div>
|
||||
)}
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{project.lastCommitAuthor && <span>von {project.lastCommitAuthor}</span>}
|
||||
{lastPush && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<FaClock /> {new Date(lastPush).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { FaChevronDown, FaPlay, FaStop, FaPlus, FaPen } from 'react-icons/fa6'
|
||||
import { FaChevronDown, FaPlay, FaStop, FaPlus } from 'react-icons/fa6'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import StatusDropdown from './StatusDropdown'
|
||||
@@ -9,8 +9,7 @@ import CreateWorksheetModal from './CreateWorksheetModal'
|
||||
import WorksheetList from './WorksheetList'
|
||||
import WorksheetTimesModal from './WorksheetTimesModal'
|
||||
import WorksheetSummary from './WorksheetSummary'
|
||||
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||
import TicketProjectsModal from './TicketProjectsModal'
|
||||
import TicketProjects from './TicketProjects'
|
||||
import TicketRoadmapPanel from './TicketRoadmapPanel'
|
||||
import ProjectContextPanel from './ProjectContextPanel'
|
||||
import InvoicePanel from './InvoicePanel'
|
||||
@@ -36,8 +35,6 @@ export default function TicketCard({ ticket, onUpdate }) {
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
|
||||
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||
|
||||
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheet, updateWorksheetTimes, getTotalTime } = useWorksheets(
|
||||
expanded ? ticket.woid : null
|
||||
@@ -125,10 +122,7 @@ export default function TicketCard({ ticket, onUpdate }) {
|
||||
</div>
|
||||
<ProjectContextPanel ticket={ticket} />
|
||||
<TicketRoadmapPanel ticket={ticket} />
|
||||
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
||||
<FaPen /> Projekte zuweisen / bearbeiten
|
||||
</button>
|
||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||
<TicketProjects ticket={ticket} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -164,7 +158,6 @@ export default function TicketCard({ ticket, onUpdate }) {
|
||||
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
|
||||
/>
|
||||
)}
|
||||
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FaPlus, FaLink } from 'react-icons/fa6'
|
||||
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)
|
||||
}
|
||||
|
||||
// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht
|
||||
// archiviert, keine Vorlage und noch nicht an diesem Ticket sind
|
||||
function assignableProjects(all, ticket, assignedIds) {
|
||||
return (all || []).filter((p) =>
|
||||
p.repoFullName &&
|
||||
!assignedIds.has(p.$id) &&
|
||||
p.status !== 'archived' &&
|
||||
!p.isTemplate &&
|
||||
(!p.customerId || p.customerId === ticket.customerId)
|
||||
)
|
||||
}
|
||||
|
||||
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
|
||||
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
|
||||
export default function TicketProjects({ ticket }) {
|
||||
const { fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } = useWebsiteProjects()
|
||||
const [assigned, setAssigned] = useState([])
|
||||
const [available, setAvailable] = useState([])
|
||||
const [selected, setSelected] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!ticket?.$id) return
|
||||
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
|
||||
setAssigned(mine)
|
||||
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
|
||||
}, [ticket, fetchAllProjects, fetchByTicketId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const run = async (action) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await action()
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Aktion fehlgeschlagen')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const assign = () => run(async () => {
|
||||
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
|
||||
if (!r.success) throw new Error(r.error)
|
||||
setSelected('')
|
||||
})
|
||||
|
||||
const unassign = (projectId) => run(async () => {
|
||||
const r = await unassignProject(projectId)
|
||||
if (!r.success) throw new Error(r.error)
|
||||
})
|
||||
|
||||
const create = (e) => {
|
||||
e.preventDefault()
|
||||
run(async () => {
|
||||
const subdomain = slugify(createForm.subdomain)
|
||||
await createProjectFromTemplate({
|
||||
repoName: subdomain,
|
||||
displayName: createForm.displayName,
|
||||
subdomain,
|
||||
customerId: ticket.customerId || '',
|
||||
ticketId: ticket.$id,
|
||||
})
|
||||
setCreateForm({ displayName: '', subdomain: '' })
|
||||
setShowCreate(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card pad ticket-projects">
|
||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
{assigned.length === 0
|
||||
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
|
||||
: assigned.map((p) => <AssignedProjectCard key={p.$id} project={p} showActions onUnassign={unassign} />)}
|
||||
|
||||
<div className="ticket-projects-assign">
|
||||
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<option value="">Projekt wählen …</option>
|
||||
{available.map((p) => (
|
||||
<option key={p.$id} value={p.$id}>{p.projectName} ({p.subdomain || p.repoFullName})</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
|
||||
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
|
||||
<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 }))} required />
|
||||
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -55,11 +55,12 @@ export function useWebsiteProjects() {
|
||||
return results.find((r) => !r.success) || { success: true }
|
||||
}
|
||||
|
||||
// Nur vom Ticket loesen: customerId bleibt, sonst verliert der Kunde die Vorschau im Portal
|
||||
const unassignProject = async (projectId) => {
|
||||
try {
|
||||
await databases.updateDocument(
|
||||
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
|
||||
{ customerId: '', ticketId: '', updatedAt: new Date().toISOString() }
|
||||
{ ticketId: '', updatedAt: new Date().toISOString() }
|
||||
)
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
|
||||
@@ -43,10 +43,3 @@ export async function archiveWebsiteProject(id) {
|
||||
export async function unarchiveWebsiteProject(id) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}/unarchive`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function fetchProjectReadme(repoFullName) {
|
||||
if (!repoFullName) return { found: false }
|
||||
return adminFetch(
|
||||
`/api/admin/gitea/repo-readme?repoFullName=${encodeURIComponent(repoFullName)}`
|
||||
)
|
||||
}
|
||||
+4
-102
@@ -593,108 +593,10 @@ textarea.form-control {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.ticket-projects { margin-top: 16px; }
|
||||
/* Zeile unter der Projektliste: Projekt waehlen + Zuweisen + Neues Projekt */
|
||||
.ticket-projects-assign { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.ticket-projects-assign .form-control { flex: 1 1 220px; margin: 0; }
|
||||
|
||||
.modal-content {
|
||||
background: rgba(45, 55, 72, 0.98) !important;
|
||||
|
||||
Reference in new issue
Block a user