504 lines
21 KiB
React
504 lines
21 KiB
React
import { useState, useEffect, useCallback } from 'react'
|
||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui'
|
||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||
import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen'
|
||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
|
||
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
||
import { lastRejection } from '../lib/leadDuplicates'
|
||
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
|
||
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
|
||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||
import { ticketNumber } from '../lib/ticketView'
|
||
import { isRowActivation } from '../lib/rowClick'
|
||
import CustomerViewLog from '../components/customer/CustomerViewLog'
|
||
import ChatThread from '../components/chat/ChatThread'
|
||
import AboStatusCard from '../components/customer/AboStatusCard'
|
||
import CustomerFunktionen from '../components/customer/CustomerFunktionen'
|
||
import CreditsView from '../components/credits/CreditsView'
|
||
import OffersView from '../components/offers/OffersView'
|
||
import KundenansichtButton from '../components/customer/KundenansichtButton'
|
||
import { getCustomerPortalStatus } from '../lib/teamApi'
|
||
import { formatLastLogin } from '../lib/lastLogin'
|
||
|
||
const PORTAL_URL = 'https://project.webklar.com'
|
||
|
||
// Rueckmeldung neben einem Knopf: err und warn mit role="alert", ok ohne
|
||
function Msg({ msg }) {
|
||
if (!msg) return null
|
||
return <span className={`msg ${msg.tone}`} role={msg.tone === 'ok' ? undefined : 'alert'}>{msg.text}</span>
|
||
}
|
||
|
||
export default function CustomerDetailPage() {
|
||
const { id } = useParams()
|
||
const [customer, setCustomer] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState(null)
|
||
// Reiter steht in der Adresse (/customers/<id>?tab=protokoll), damit Sammellisten und Team-Mails direkt hineinspringen
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const tab = searchParams.get('tab') || 'overview'
|
||
const setTab = useCallback(
|
||
(next) => setSearchParams(next === 'overview' ? {} : { tab: next }, { replace: true }),
|
||
[setSearchParams]
|
||
)
|
||
const { isAdmin } = useAuth()
|
||
const navigate = useNavigate()
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
setCustomer(await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id))
|
||
setError(null)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [id])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
|
||
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers">Zurück</Button>} /></div>
|
||
|
||
const stage = customerStage(customer)
|
||
// „Rechnungen“ nur für Admins (die Finanzen-API gibt sonst 403); ein nicht erlaubter Reiter fällt auf die Übersicht zurück
|
||
const activeTab = resolveCustomerTab(tab, isAdmin)
|
||
const archived = isArchived(customer)
|
||
const title = customerTitle(customer)
|
||
|
||
const toggleArchived = async () => {
|
||
await setCustomerArchived(customer, !archived)
|
||
await load()
|
||
}
|
||
|
||
// Loescht Kunde und Portal-Login ueber die Admin-API des Kundenportals (nur Admins)
|
||
const removeCustomer = async () => {
|
||
if (!window.confirm(`${customer.name || customer.email} und den Portal-Zugang wirklich löschen?`)) return
|
||
try {
|
||
await deleteCustomerWithPortalAccess(customer.$id)
|
||
navigate('/customers')
|
||
} catch (err) {
|
||
window.alert(`Löschen fehlgeschlagen: ${err.message}`)
|
||
}
|
||
}
|
||
|
||
// Unter dem Namen: Abzeichen (Wichtig, Archiviert; die Stufe steht in der Eyebrow), dann Standort · E-Mail · Telefon
|
||
const badges = customerBadges(customer, { stage: false })
|
||
const contact = [customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')
|
||
const subtitle = badges.length > 0 || contact ? (
|
||
<span className="kunde-sub">
|
||
{badges.map((b) => (
|
||
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
|
||
))}
|
||
{contact}
|
||
</span>
|
||
) : null
|
||
|
||
return (
|
||
<div className="page">
|
||
<PageHeader
|
||
crumb={[{ label: 'Kunden', to: '/customers' }, { label: title }]}
|
||
eyebrow={customerEyebrow(customer)}
|
||
title={title}
|
||
subtitle={subtitle}
|
||
actions={
|
||
<>
|
||
{isAdmin && <Button variant="danger" onClick={removeCustomer}>Kunde löschen</Button>}
|
||
<Button onClick={toggleArchived}>{archived ? 'Wiederherstellen' : 'Archivieren'}</Button>
|
||
<KundenansichtButton customer={customer} />
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<Tabs tabs={customerTabs(isAdmin)} active={activeTab} onChange={setTab} />
|
||
|
||
{/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */}
|
||
{activeTab === 'overview' && <OverviewTab key={customer.$updatedAt} customer={customer} stage={stage} onSaved={load} />}
|
||
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
||
{activeTab === 'invoices' && <AuftraegeUndRechnungen key={customer.$id} customer={customer} onShowOverview={() => setTab('overview')} />}
|
||
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
|
||
{activeTab === 'abo' && (
|
||
<>
|
||
<AboStatusCard customerId={customer.$id} />
|
||
<div className="kunde-abo-angebote"><OffersView customerId={customer.$id} customerName={customer.name} /></div>
|
||
</>
|
||
)}
|
||
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
|
||
{activeTab === 'funktionen' && <CustomerFunktionen customerId={customer.$id} />}
|
||
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function OverviewTab({ customer, stage, onSaved }) {
|
||
const [form, setForm] = useState({
|
||
name: customer.name || '',
|
||
companyName: customer.companyName || customer.name || '',
|
||
code: customer.code || '',
|
||
email: customer.email || '',
|
||
phone: customer.phone || '',
|
||
contactName: customer.contactName || '',
|
||
street: customer.street || '',
|
||
postalCode: customer.postalCode || '',
|
||
city: customer.city || '',
|
||
country: customer.country || 'Deutschland',
|
||
vatNumber: customer.vatNumber || '',
|
||
location: customer.location || '',
|
||
notes: customer.notes || '',
|
||
importantCustomer: !!customer.importantCustomer,
|
||
})
|
||
const [kundenart, setKundenart] = useState(customer.kundenart || '')
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||
const [showLost, setShowLost] = useState(false)
|
||
const { user, employee } = useAuth()
|
||
|
||
// Die Kundenart geht nur ins Dokument, wenn sie geaendert wurde (kein Fehler bei noch nicht angelegtem Attribut)
|
||
const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) })
|
||
|
||
const ready = isInvoiceReady({ ...customer, ...form })
|
||
|
||
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
|
||
const saveLost = async (reason) => {
|
||
await markLost({ ...customer, notes: form.notes }, { reason, by: employee?.displayName || user?.name || '' })
|
||
setShowLost(false)
|
||
onSaved()
|
||
}
|
||
|
||
const reopen = async () => {
|
||
setBusy(true); setMsg(null)
|
||
try {
|
||
await reopenAsLead(customer)
|
||
onSaved()
|
||
} catch (err) {
|
||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const save = async () => {
|
||
setBusy(true); setMsg(null)
|
||
try {
|
||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, felder())
|
||
setMsg({ tone: 'ok', text: 'Gespeichert' })
|
||
onSaved()
|
||
} catch (err) {
|
||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||
} finally { setBusy(false) }
|
||
}
|
||
|
||
const upgrade = async () => {
|
||
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
|
||
setBusy(true); setMsg(null)
|
||
try {
|
||
await upgradeToCustomer(customer, felder())
|
||
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
||
onSaved()
|
||
} catch (err) {
|
||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||
} finally { setBusy(false) }
|
||
}
|
||
|
||
const rejection = lastRejection(customer.notes)
|
||
|
||
return (
|
||
<div className="two-col">
|
||
<div>
|
||
{stage === 'lost' && (
|
||
<div className="kunde-absage" role="note">
|
||
<strong>Hat abgesagt – bitte nicht erneut anrufen.</strong>
|
||
{rejection && <div>{rejection}</div>}
|
||
<div className="toolbar">
|
||
<Button onClick={reopen} disabled={busy}>Wieder als Lead führen</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Card title="Stammdaten">
|
||
<dl className="kv kunde-kv">
|
||
<dt><label htmlFor="kunde-name">Unternehmen / Name</label></dt>
|
||
<dd><input id="kunde-name" className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-code">Code</label></dt>
|
||
<dd><input id="kunde-code" className="input" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-email">E-Mail</label></dt>
|
||
<dd><input id="kunde-email" className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-phone">Telefon</label></dt>
|
||
<dd><input id="kunde-phone" className="input" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-location">Standort</label></dt>
|
||
<dd><input id="kunde-location" className="input" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-contact">Ansprechpartner</label></dt>
|
||
<dd><input id="kunde-contact" className="input" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></dd>
|
||
<dt className="kunde-kv-oben"><label htmlFor="kunde-notes">Notizen</label></dt>
|
||
<dd><textarea id="kunde-notes" className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></dd>
|
||
</dl>
|
||
<label className="check">
|
||
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
||
Wichtiger Kunde
|
||
</label>
|
||
<div className="toolbar">
|
||
<Button variant="primary" onClick={save} disabled={busy}>{busy ? 'Speichert …' : 'Speichern'}</Button>
|
||
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}>Zum festen Kunden machen</Button>}
|
||
{stage === 'lead' && <Button onClick={() => setShowLost(true)} disabled={busy}>Absage eintragen</Button>}
|
||
<Msg msg={msg} />
|
||
</div>
|
||
</Card>
|
||
{showLost && <LostDialog name={customer.name} onCancel={() => setShowLost(false)} onSave={saveLost} />}
|
||
|
||
<Card title="Rechnungsdaten">
|
||
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
|
||
<dl className="kv kunde-kv">
|
||
<dt><label htmlFor="kunde-kundenart">Kundenart</label></dt>
|
||
<dd>
|
||
<select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}>
|
||
{KUNDENART_OPTIONEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
<div className="hint">{KUNDENART_HINWEIS}</div>
|
||
</dd>
|
||
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
|
||
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
|
||
<dd><input id="kunde-postal" className="input" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-city">Stadt</label></dt>
|
||
<dd><input id="kunde-city" className="input" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-country">Land</label></dt>
|
||
<dd><input id="kunde-country" className="input" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></dd>
|
||
<dt><label htmlFor="kunde-vat">USt-IdNr.</label></dt>
|
||
<dd><input id="kunde-vat" className="input" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></dd>
|
||
</dl>
|
||
</Card>
|
||
</div>
|
||
|
||
<div>
|
||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Absage eintragen: Grund ist freiwillig; Datum und Person setzt markLost
|
||
function LostDialog({ name, onCancel, onSave }) {
|
||
const [reason, setReason] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
|
||
const submit = async (e) => {
|
||
e.preventDefault()
|
||
setBusy(true); setError(null)
|
||
try {
|
||
await onSave(reason.trim())
|
||
} catch (err) {
|
||
setError(err.message || 'Fehler')
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog
|
||
as="form"
|
||
title={`Absage von ${name || 'diesem Lead'}`}
|
||
busy={busy}
|
||
onClose={onCancel}
|
||
onSubmit={submit}
|
||
footer={
|
||
<>
|
||
<span className="grow" />
|
||
<Button type="button" disabled={busy} onClick={onCancel}>Abbrechen</Button>
|
||
<Button variant="primary" type="submit" disabled={busy}>{busy ? 'Speichert …' : 'Absage speichern'}</Button>
|
||
</>
|
||
}
|
||
>
|
||
{error && <div className="msg err" role="alert">{error}</div>}
|
||
<p className="muted small">
|
||
Der Eintrag wandert in den Reiter „Abgesagt“ und wird beim Anlegen neuer Leads als Warnung angezeigt.
|
||
Offene Akquise-Tickets werden auf „Abgesagt“ gesetzt. Datum und dein Name kommen in die Notizen.
|
||
</p>
|
||
<Field label="Grund (freiwillig)" htmlFor="lost-reason">
|
||
<textarea id="lost-reason" className="textarea" rows={3} value={reason} onChange={(e) => setReason(e.target.value)}
|
||
placeholder="z. B. kein Bedarf, hat schon eine Agentur, nicht vor 2027 melden" />
|
||
</Field>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function PortalAccessCard({ customer, onSaved }) {
|
||
const [password, setPassword] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||
|
||
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||
const currentPassword = getCustomerPortalPassword(customer)
|
||
// Letzter Login kommt vom Portal (customerPortalAccess), nicht aus dem Kunden-Dokument
|
||
const [lastLogin, setLastLogin] = useState(undefined)
|
||
useEffect(() => {
|
||
let active = true
|
||
getCustomerPortalStatus(customer.$id)
|
||
.then((status) => active && setLastLogin(status.lastLoginAt || null))
|
||
.catch(() => active && setLastLogin(false))
|
||
return () => { active = false }
|
||
}, [customer.$id])
|
||
|
||
const savePassword = async () => {
|
||
if (!customer.email) {
|
||
setMsg({ tone: 'warn', text: 'Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.' })
|
||
return
|
||
}
|
||
if (!password || password.length < 8) {
|
||
setMsg({ tone: 'warn', text: 'Das Passwort muss mindestens 8 Zeichen haben.' })
|
||
return
|
||
}
|
||
setBusy(true); setMsg(null)
|
||
try {
|
||
await updateCustomerWithPortalAccess(customer.$id, { password })
|
||
setPassword('')
|
||
setMsg({ tone: 'ok', text: hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.' })
|
||
onSaved()
|
||
} catch (err) {
|
||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card title="Portal-Zugang">
|
||
<dl className="kv kunde-kv">
|
||
<dt>Kundenportal</dt>
|
||
<dd>
|
||
{hasAccess
|
||
? <Badge tone="ok">freigeschaltet</Badge>
|
||
: <Badge tone="muted">kein Zugang</Badge>}
|
||
</dd>
|
||
<dt>Letzter Login</dt>
|
||
<dd>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</dd>
|
||
<dt>Login (E-Mail)</dt>
|
||
<dd><CopyableCredential value={customer.email} label="Login" /></dd>
|
||
<dt>Passwort</dt>
|
||
<dd><CopyableCredential value={currentPassword} secret label="Passwort" /></dd>
|
||
<dt><label htmlFor="kunde-neues-passwort">{hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}</label></dt>
|
||
<dd>
|
||
<input
|
||
id="kunde-neues-passwort"
|
||
className="input"
|
||
type="text"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="min. 8 Zeichen"
|
||
autoComplete="off"
|
||
/>
|
||
</dd>
|
||
</dl>
|
||
|
||
<div className="toolbar">
|
||
<Button onClick={savePassword} disabled={busy}>
|
||
{busy ? 'Speichert …' : hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}
|
||
</Button>
|
||
<KundenansichtButton customer={customer} />
|
||
<Msg msg={msg} />
|
||
</div>
|
||
<p className="muted small">
|
||
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 navigate = useNavigate()
|
||
const [tickets, setTickets] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
;(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||
Query.equal('customerId', customerId),
|
||
Query.orderDesc('$createdAt'),
|
||
Query.limit(100),
|
||
])
|
||
if (active) setTickets(res.documents)
|
||
} catch {
|
||
if (active) setTickets([])
|
||
} finally {
|
||
if (active) setLoading(false)
|
||
}
|
||
})()
|
||
return () => { active = false }
|
||
}, [customerId])
|
||
|
||
if (loading) return <div className="loading">Wird geladen …</div>
|
||
if (tickets.length === 0) return <EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." />
|
||
|
||
return (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Ticket</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tickets.map((t) => {
|
||
const path = ticketPath(ticketRef(t))
|
||
return (
|
||
<tr
|
||
key={t.$id}
|
||
className="row"
|
||
onClick={(e) => {
|
||
if (isRowActivation(e)) navigate(path)
|
||
}}
|
||
>
|
||
<td>
|
||
<div className="cell-title">
|
||
<Link className="row-link" to={path}>
|
||
<span className="id">{ticketNumber(t)}</span>
|
||
<span>{t.topic || t.title || '-'}</span>
|
||
</Link>
|
||
<UrgentPill priority={t.priority} />
|
||
</div>
|
||
{t.type && <div className="cell-sub">{t.type}</div>}
|
||
</td>
|
||
<td><StatusPill status={t.status} /></td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)
|
||
}
|
||
|
||
function CustomerProjects({ customerId }) {
|
||
const { fetchAllProjects } = useWebsiteProjects()
|
||
const [projects, setProjects] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
;(async () => {
|
||
setLoading(true)
|
||
const all = await fetchAllProjects()
|
||
if (active) setProjects((all || []).filter((p) => p.customerId === customerId))
|
||
if (active) setLoading(false)
|
||
})()
|
||
return () => { active = false }
|
||
}, [customerId, fetchAllProjects])
|
||
|
||
if (loading) return <div className="loading">Wird geladen …</div>
|
||
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
|
||
|
||
return (
|
||
<Card title={`Projekte (${projects.length})`}>
|
||
{projects.map((p) => <AssignedProjectCard key={p.$id} project={p} />)}
|
||
</Card>
|
||
)
|
||
}
|