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:
@@ -84,24 +84,13 @@ export async function createAcquisitionTicket(customer, user) {
|
||||
|
||||
/** Legt einen Lead an (nur Unternehmen + E-Mail) und erstellt das Akquise-Ticket. */
|
||||
export async function createLead({ company, email }, user) {
|
||||
let customer
|
||||
try {
|
||||
customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
|
||||
name: company,
|
||||
companyName: company,
|
||||
email: email || '',
|
||||
customerStatus: 'lead',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7284/ingest/0747da40-b90b-4354-9b84-c9b550a81ec9', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '693b5f' }, body: JSON.stringify({ sessionId: '693b5f', runId: 'post-fix', hypothesisId: 'A', location: 'leads.js:createLead', message: 'lead created ok', data: { id: customer.$id }, timestamp: Date.now() }) }).catch(() => {})
|
||||
// #endregion
|
||||
} catch (err) {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7284/ingest/0747da40-b90b-4354-9b84-c9b550a81ec9', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '693b5f' }, body: JSON.stringify({ sessionId: '693b5f', runId: 'post-fix', hypothesisId: 'A', location: 'leads.js:createLead', message: 'lead create FAILED', data: { error: String(err && err.message) }, timestamp: Date.now() }) }).catch(() => {})
|
||||
// #endregion
|
||||
throw err
|
||||
}
|
||||
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
|
||||
name: company,
|
||||
companyName: company,
|
||||
email: email || '',
|
||||
customerStatus: 'lead',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
let ticket = null
|
||||
try {
|
||||
ticket = await createAcquisitionTicket(customer, user)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp } from 'react-icons/fa6'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
||||
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
||||
import InvoicePanel from '../components/InvoicePanel'
|
||||
import DocumentsPanel from '../components/DocumentsPanel'
|
||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
|
||||
import { updateCustomerWithPortalAccess } from '../lib/customerAdminApi'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerStage, isInvoiceReady, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
|
||||
|
||||
const PORTAL_URL = 'https://project.webklar.com'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'tickets', label: 'Tickets' },
|
||||
@@ -57,7 +61,7 @@ export default function CustomerDetailPage() {
|
||||
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||
</span>
|
||||
}
|
||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' <20> ')}
|
||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' <20> ')}
|
||||
actions={<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>}
|
||||
/>
|
||||
|
||||
@@ -172,10 +176,86 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
<input className="form-control" value={form.paperlessCorrespondent} onChange={(e) => setForm({ ...form, paperlessCorrespondent: e.target.value })} placeholder={customer.name} />
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalAccessCard({ customer, onSaved }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
|
||||
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||||
const currentPassword = getCustomerPortalPassword(customer)
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!customer.email) {
|
||||
setMsg('Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.')
|
||||
return
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
setMsg('Das Passwort muss mindestens 8 Zeichen haben.')
|
||||
return
|
||||
}
|
||||
setBusy(true); setMsg(null)
|
||||
try {
|
||||
await updateCustomerWithPortalAccess(customer.$id, { password })
|
||||
setPassword('')
|
||||
setMsg(hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.')
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setMsg('Fehler: ' + err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Portal-Zugang">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
||||
<span className="muted">Kundenportal</span>
|
||||
{hasAccess
|
||||
? <Badge tone="ok" dot>freigeschaltet</Badge>
|
||||
: <Badge tone="muted">kein Zugang</Badge>}
|
||||
</div>
|
||||
|
||||
<Field label="Login (E-Mail)">
|
||||
<CopyableCredential value={customer.email} label="Login" />
|
||||
</Field>
|
||||
<Field label="Passwort">
|
||||
<CopyableCredential value={currentPassword} secret label="Passwort" />
|
||||
</Field>
|
||||
|
||||
<Field label={hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}>
|
||||
<input
|
||||
className="form-control"
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="min. 8 Zeichen"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-2 items-center wrap">
|
||||
<Button variant="primary" onClick={savePassword} disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
|
||||
</Button>
|
||||
<Button as="a" href={PORTAL_URL} target="_blank" rel="noreferrer" variant="ghost">
|
||||
<FaArrowUpRightFromSquare /> Portal öffnen
|
||||
</Button>
|
||||
{msg && <span className="muted">{msg}</span>}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
|
||||
und sieht dort seine Projekte/Previews.
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerTickets({ customerId }) {
|
||||
const [tickets, setTickets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -211,7 +291,7 @@ function CustomerTickets({ customerId }) {
|
||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||
<div className="muted">{t.type} <EFBFBD> {t.systemType || 'n/a'}</div>
|
||||
<div className="muted">{t.type} <EFBFBD> {t.systemType || 'n/a'}</div>
|
||||
</div>
|
||||
<PriorityPill priority={t.priority} />
|
||||
<StatusPill status={t.status} />
|
||||
|
||||
@@ -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://<subdomain>.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">
|
||||
|
||||
Reference in New Issue
Block a user