diff --git a/src/lib/leads.js b/src/lib/leads.js
index 9eb7bf4..eea4a0d 100644
--- a/src/lib/leads.js
+++ b/src/lib/leads.js
@@ -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)
diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx
index cceae01..578325b 100644
--- a/src/pages/CustomerDetailPage.jsx
+++ b/src/pages/CustomerDetailPage.jsx
@@ -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' && Abgesagt }
}
- subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' ')}
+ subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' � ')}
actions={ Alle Kunden }
/>
@@ -172,10 +176,86 @@ function OverviewTab({ customer, stage, onSaved }) {
setForm({ ...form, paperlessCorrespondent: e.target.value })} placeholder={customer.name} />
+
+
)
}
+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 (
+
+
+ Kundenportal
+ {hasAccess
+ ? freigeschaltet
+ : kein Zugang }
+
+
+
+
+
+
+
+
+
+
+ setPassword(e.target.value)}
+ placeholder="min. 8 Zeichen"
+ autoComplete="off"
+ />
+
+
+
+
+ {busy ? : <> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}>}
+
+
+ Portal öffnen
+
+ {msg && {msg} }
+
+
+ Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
+ und sieht dort seine Projekte/Previews.
+
+
+ )
+}
+
function CustomerTickets({ customerId }) {
const [tickets, setTickets] = useState([])
const [loading, setLoading] = useState(true)
@@ -211,7 +291,7 @@ function CustomerTickets({ customerId }) {
{t.woid || t.$id.slice(-5)}
{t.topic || t.title || '-'}
-
{t.type} {t.systemType || 'n/a'}
+
{t.type} � {t.systemType || 'n/a'}
diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx
index 9f9d615..f4ebee2 100644
--- a/src/pages/ProjectsPage.jsx
+++ b/src/pages/ProjectsPage.jsx
@@ -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 (
@@ -104,8 +137,17 @@ export default function ProjectsPage() {
setShowCreateModal(true)}>
Neues Projekt
+
+ {syncLoading ? 'Synchronisiere...' : 'Gitea-Repos synchronisieren'}
+
+ {notice && (
+
+ {notice}
+
+ )}
+
{error && (
{error}
@@ -137,9 +179,24 @@ export default function ProjectsPage() {
{filteredProjects.map((project) => (
{project.projectName}
- {project.subdomain}
+ {project.subdomain || ohne Website }
{project.templateName || '-'}
- {project.customerId ? customerMap[project.customerId] || project.customerId : '-'}
+
+ handleAssignCustomer(project, e.target.value)}
+ >
+ Kein Kunde
+ {customers.map((c) => (
+
+ ({c.code || ''}) {c.name}
+
+ ))}
+
+
{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}
{project.status || '-'}
@@ -177,7 +234,7 @@ export default function ProjectsPage() {
/>
- Subdomain
+ Subdomain (optional)
+
+ Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt.
+ Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
+
Repo-Name (optional)
@@ -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"
/>