feat: Projekte ohne Subdomain, Portal-Zugang unter Uebersicht, Kunden-Zuordnung

- ProjectsPage: Subdomain optional (ohne = normales Projekt, nur Gitea-Repo);
  Kunde direkt in der Tabelle zuordnen; Button 'Gitea-Repos synchronisieren'
- CustomerDetailPage: Card 'Portal-Zugang' unter Uebersicht - Passwort setzen/
  aendern (min. 8 Zeichen), Login+Passwort kopierbar, Link zum Kundenportal
- leads.js: uebrig gebliebene Debug-Instrumentierung entfernt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:40:10 +00:00
parent f76b738f5f
commit 4be20237a1
3 changed files with 161 additions and 30 deletions

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react'
import { FaFolder, FaPlus } from 'react-icons/fa6'
import { FaFolder, FaPlus, FaArrowsRotate } from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { useCustomers } from '../hooks/useCustomers'
import { useWorkorders } from '../hooks/useWorkorders'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import { createProjectFromTemplate, syncGiteaRepos } from '../lib/projectAdminApi'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
@@ -18,7 +18,7 @@ function slugify(value) {
const WORKORDER_FILTERS = { limit: 500 }
export default function ProjectsPage() {
const { projects, loading, error, fetchAllProjects } = useWebsiteProjects()
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS)
const [filter, setFilter] = useState('all')
@@ -31,6 +31,9 @@ export default function ProjectsPage() {
})
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState('')
const [syncLoading, setSyncLoading] = useState(false)
const [notice, setNotice] = useState('')
const [assignBusyId, setAssignBusyId] = useState('')
useEffect(() => {
fetchAllProjects()
@@ -67,8 +70,8 @@ export default function ProjectsPage() {
setCreateLoading(true)
setCreateError('')
try {
await createProjectFromTemplate({
repoName: createForm.repoName || createForm.subdomain,
const result = await createProjectFromTemplate({
repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName),
displayName: createForm.displayName,
subdomain: slugify(createForm.subdomain),
customerId: createForm.customerId || '',
@@ -76,6 +79,11 @@ export default function ProjectsPage() {
})
setShowCreateModal(false)
setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' })
setNotice(
result.previewUrl
? `Projekt angelegt Website: ${result.previewUrl}`
: 'Projekt (ohne Website) angelegt.'
)
await fetchAllProjects()
} catch (err) {
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
@@ -84,6 +92,31 @@ export default function ProjectsPage() {
}
}
const handleSyncRepos = async () => {
setSyncLoading(true)
setNotice('')
try {
const r = await syncGiteaRepos()
setNotice(`Gitea-Sync: ${r.synced ?? '?'} Repos (${r.created ?? 0} neu, ${r.updated ?? 0} aktualisiert)`)
await fetchAllProjects()
} catch (err) {
setNotice('Gitea-Sync fehlgeschlagen: ' + (err.message || 'unbekannt'))
} finally {
setSyncLoading(false)
}
}
const handleAssignCustomer = async (project, customerId) => {
setAssignBusyId(project.$id)
setNotice('')
const r = customerId
? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
: await unassignProject(project.$id)
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
await fetchAllProjects()
setAssignBusyId('')
}
return (
<div className="main-content">
<header className="text-center mb-2">
@@ -104,8 +137,17 @@ export default function ProjectsPage() {
<button type="button" className="btn btn-teal" onClick={() => setShowCreateModal(true)}>
<FaPlus /> Neues Projekt
</button>
<button type="button" className="btn btn-dark" onClick={handleSyncRepos} disabled={syncLoading}>
<FaArrowsRotate /> {syncLoading ? 'Synchronisiere...' : 'Gitea-Repos synchronisieren'}
</button>
</div>
{notice && (
<div className="text-center mb-2">
<span className="text-grey">{notice}</span>
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
@@ -137,9 +179,24 @@ export default function ProjectsPage() {
{filteredProjects.map((project) => (
<tr key={project.$id}>
<td>{project.projectName}</td>
<td>{project.subdomain}</td>
<td>{project.subdomain || <span className="text-grey">ohne Website</span>}</td>
<td>{project.templateName || '-'}</td>
<td>{project.customerId ? customerMap[project.customerId] || project.customerId : '-'}</td>
<td>
<select
className="form-control"
style={{ minWidth: '140px', fontSize: '13px' }}
value={project.customerId || ''}
disabled={assignBusyId === project.$id}
onChange={(e) => handleAssignCustomer(project, e.target.value)}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</td>
<td>{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}</td>
<td>{project.status || '-'}</td>
<td>
@@ -177,7 +234,7 @@ export default function ProjectsPage() {
/>
</div>
<div className="form-group">
<label className="form-label">Subdomain</label>
<label className="form-label">Subdomain (optional)</label>
<input
type="text"
className="form-control"
@@ -189,8 +246,12 @@ export default function ProjectsPage() {
repoName: p.repoName || slugify(e.target.value),
}))
}
required
placeholder="leer lassen = Projekt ohne Website"
/>
<small className="text-grey">
Mit Subdomain wird die Website automatisch unter https://&lt;subdomain&gt;.project.webklar.com deployt.
Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
</small>
</div>
<div className="form-group">
<label className="form-label">Repo-Name (optional)</label>
@@ -199,6 +260,7 @@ export default function ProjectsPage() {
className="form-control"
value={createForm.repoName}
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
/>
</div>
<div className="form-group">