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={} /> @@ -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" + /> + + +
+ + + {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() { +
+ {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 : '-'} + + + {project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'} {project.status || '-'} @@ -177,7 +234,7 @@ export default function ProjectsPage() { />
- + + + Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt. + Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt. +
@@ -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" />