Gutschriften im Ticketsystem: Sammelliste, Pruefung und Entscheidung, Kunden-Reiter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cef0d5465e
commit
109aef6805
8 files changed
+418
-3
No files matched your search
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button } from '../ui'
|
||||
import KundenansichtButton from '../customer/KundenansichtButton'
|
||||
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
|
||||
import { applyMessage, BILLING_MONTHS, calcLine, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
|
||||
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
|
||||
import { storageLabel } from '../../lib/storageFormat'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
const CHECK_TONE = { ok: 'ok', warn: 'warn', info: 'info' }
|
||||
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
|
||||
const toCents = (value) => Math.round(Number(value) * 100)
|
||||
|
||||
/**
|
||||
* Antrag auf Uebernahmegutschrift: Pruefung und Entscheidung wie im frueheren Admin-Panel (public/admin.js 684-906).
|
||||
* Waehrend einer Entscheidung sind alle Knoepfe gesperrt (busyRef verhindert auch Doppelklicks vor dem Neuzeichnen).
|
||||
*/
|
||||
export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
const [d, setD] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [form, setForm] = useState(null)
|
||||
const [calcText, setCalcText] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const amountTouched = useRef(false)
|
||||
const calcTimer = useRef(null)
|
||||
|
||||
const load = useCallback(async (msg = '') => {
|
||||
setError(null)
|
||||
try {
|
||||
const detail = await getCredit(id)
|
||||
const r = detail.request
|
||||
setD(detail)
|
||||
setMessage(msg)
|
||||
setActionError('')
|
||||
amountTouched.current = false
|
||||
setForm({
|
||||
checks: Object.fromEntries(detail.requiredChecks.map((k) => [k.key, false])),
|
||||
invoiceAmount: (r.invoiceAmountCents / 100).toFixed(2),
|
||||
billingMonths: r.billingMonths,
|
||||
paidUntil: r.paidUntilDate || '',
|
||||
takeoverFrom: r.takeoverFromDate || '',
|
||||
amount: ((detail.calc?.suggestedCents || 0) / 100).toFixed(2),
|
||||
note: '',
|
||||
})
|
||||
setCalcText(detail.calc ? calcLine(detail.calc) : 'Angaben unvollständig')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Antrag konnte nicht geladen werden.')
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
setD(null)
|
||||
load()
|
||||
return () => clearTimeout(calcTimer.current)
|
||||
}, [load])
|
||||
|
||||
const figures = (f) => ({ invoiceAmountCents: toCents(f.invoiceAmount), billingMonths: Number(f.billingMonths), paidUntil: f.paidUntil, takeoverFrom: f.takeoverFrom })
|
||||
|
||||
const setFigure = (key, value) => {
|
||||
const next = { ...form, [key]: value }
|
||||
setForm(next)
|
||||
clearTimeout(calcTimer.current)
|
||||
calcTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const { calc } = await calcCredit(figures(next))
|
||||
setCalcText(calcLine(calc))
|
||||
if (!amountTouched.current) setForm((f) => ({ ...f, amount: (calc.suggestedCents / 100).toFixed(2) }))
|
||||
} catch (err) {
|
||||
setCalcText(err.message)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const decide = async (action) => {
|
||||
if (busyRef.current) return
|
||||
setActionError('')
|
||||
const problem = decisionError(action, form.note)
|
||||
if (problem) { setActionError(problem); return }
|
||||
const amountCents = toCents(form.amount)
|
||||
if (!window.confirm(decisionConfirm(action, amountCents, d.customer?.name))) return
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
|
||||
onChanged?.()
|
||||
await load(decisionMessage(action, result))
|
||||
} catch (err) {
|
||||
setActionError(err.message)
|
||||
} finally {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const apply = async () => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await applyCredit(id)
|
||||
onChanged?.()
|
||||
await load(applyMessage(result))
|
||||
} catch (err) {
|
||||
setActionError(err.message)
|
||||
} finally {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <Card><div className="area-error">{error}</div><Button variant="ghost" onClick={onClose}>Schließen</Button></Card>
|
||||
if (!d || !form) return <Card><FaSpinner className="spinner" /></Card>
|
||||
|
||||
const r = d.request
|
||||
const c = d.customer
|
||||
const s = d.subscription
|
||||
const st = creditStatus(r.status)
|
||||
const pending = r.status === 'offen' || r.status === 'rueckfrage'
|
||||
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
|
||||
const allChecked = Object.values(form.checks).every(Boolean)
|
||||
const canApprove = allChecked && Number(form.amount) > 0 && !busy
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Antrag {r.ref} · {c.name || c.companyName}</h3>
|
||||
<span className="area-muted">eingegangen {fmtDateTime(r.createdAt)}</span> <Badge tone={st.tone}>{st.label}</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onClose}>Schließen</Button>
|
||||
</div>
|
||||
{message && <div className="area-message">{message}</div>}
|
||||
<div className="credit-cols">
|
||||
<div>
|
||||
<h4>Antrag</h4>
|
||||
<dl className="area-kv">
|
||||
<dt>Leistung</dt><dd>{r.services}</dd>
|
||||
<dt>Bisher bei</dt><dd>{r.provider}</dd>
|
||||
<dt>Domain</dt><dd>{r.domain ? <a href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
|
||||
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
|
||||
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
|
||||
<dt>Antwort an</dt><dd>{r.email}</dd>
|
||||
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
|
||||
</dl>
|
||||
<h4>Grund / Nachricht</h4>
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{r.message}</p>
|
||||
<h4>Nachweise</h4>
|
||||
{r.files.length ? (
|
||||
<ul className="credit-files">
|
||||
{r.files.map((f) => (
|
||||
<li key={f.id}>
|
||||
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="area-muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="area-muted">Keine Nachweise (mehr) vorhanden.</p>}
|
||||
<h4>Verlauf</h4>
|
||||
{r.history.map((h, i) => (
|
||||
<div key={i} className="credit-history">
|
||||
<strong>{historyLabel(h.type)}</strong> <span className="area-muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
|
||||
{h.text && <div style={{ whiteSpace: 'pre-wrap' }}>{h.text}</div>}
|
||||
{(h.files || []).length > 0 && <div className="area-muted">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h4>Kunde & Vertrag</h4>
|
||||
<dl className="area-kv">
|
||||
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
|
||||
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
|
||||
<dt>Abo</dt>
|
||||
<dd>
|
||||
{s ? <>{productLabelOf(s.productKey)} <Badge tone={s.active ? 'ok' : 'danger'}>{s.status}</Badge><br /><span className="area-muted">Zusatzspeicher: {storageLabel(s.storageUnits)}</span></> : <Badge tone="danger">kein Abo</Badge>}
|
||||
</dd>
|
||||
{s && (
|
||||
<>
|
||||
<dt>Leistungsbeginn</dt><dd>{fmtDate(s.serviceStart)}</dd>
|
||||
<dt>Nächste Abbuchung</dt><dd>{s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
|
||||
{s.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(s.cancelAt)}</dd></>}
|
||||
<dt>Stripe-Guthaben</dt><dd>{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<div className="area-inline" style={{ margin: '12px 0' }}>
|
||||
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} variant="ghost" />
|
||||
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer" variant="ghost"><FaArrowUpRightFromSquare /> In Stripe öffnen</Button>}
|
||||
</div>
|
||||
{d.otherRequests.length > 0 && (
|
||||
<>
|
||||
<h4>Weitere Anträge des Kunden</h4>
|
||||
<ul className="credit-files">
|
||||
{d.otherRequests.map((o) => {
|
||||
const ost = creditStatus(o.status)
|
||||
return (
|
||||
<li key={o.id}>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); onOpen?.(o.id) }}>{o.ref}</a> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<h4>Automatische Prüfung</h4>
|
||||
<ul className="credit-checks">
|
||||
{d.autoChecks.map((k) => (
|
||||
<li key={k.key}><Badge tone={CHECK_TONE[k.status] || 'muted'}>{k.status === 'ok' ? '✓' : k.status === 'warn' ? '!' : 'i'}</Badge> {k.label}{k.detail && <><br /><small className="area-muted">{k.detail}</small></>}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{pending ? (
|
||||
<>
|
||||
<h4>Prüfung durch dich</h4>
|
||||
<div className="credit-manual">
|
||||
{d.requiredChecks.map((k) => (
|
||||
<label key={k.key}>
|
||||
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<h4>Berechnung</h4>
|
||||
<div className="credit-calc">
|
||||
<label>Rechnungsbetrag (€)<input id="calc-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} /></label>
|
||||
<label>Rechnung gilt für
|
||||
<select id="calc-months" className="form-control" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
|
||||
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Bezahlt bis<input id="calc-paid-until" className="form-control" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} /></label>
|
||||
<label>Übernahme ab<input id="calc-from" className="form-control" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="area-summary">{calcText}</div>
|
||||
<p className="area-muted">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
|
||||
<label className="credit-field">Gutschrift (€)
|
||||
<input id="credit-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.amount} style={{ maxWidth: 180 }}
|
||||
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
|
||||
</label>
|
||||
<label className="credit-field">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
|
||||
<textarea id="credit-note" className="form-control" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||
</label>
|
||||
<div className="area-inline">
|
||||
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>Genehmigen & gutschreiben</Button>
|
||||
<Button variant="ghost" onClick={() => decide('question')} disabled={busy}>Rückfrage senden</Button>
|
||||
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>Ablehnen</Button>
|
||||
{busy && <FaSpinner className="spinner" />}
|
||||
</div>
|
||||
<p className="area-muted">
|
||||
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
|
||||
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4>Entscheidung</h4>
|
||||
{r.status === 'genehmigt' ? (
|
||||
<>
|
||||
<dl className="area-kv">
|
||||
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
||||
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
|
||||
<dt>Berechnung</dt><dd>{r.calculation}</dd>
|
||||
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
|
||||
{r.decisionNote && <><dt>Nachricht</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd></>}
|
||||
<dt>Verrechnung</dt>
|
||||
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="area-muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
|
||||
</dl>
|
||||
{!r.appliedAt && <div className="area-inline" style={{ marginTop: 12 }}><Button variant="primary" onClick={apply} disabled={busy}>Jetzt in Stripe gutschreiben</Button></div>}
|
||||
</>
|
||||
) : (
|
||||
<dl className="area-kv">
|
||||
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
||||
<dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{actionError && <div className="area-error">{actionError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button } from '../ui'
|
||||
import { listCredits } from '../../lib/teamApi'
|
||||
import { creditStatus } from '../../lib/creditFormat'
|
||||
import { fmtDate, fmtDateTime, formatCents } from '../../lib/format'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
// Liste der Uebernahmegutschriften (Filter "Offen"/"Alle"), wie im frueheren Admin-Panel; optional nur ein Kunde
|
||||
export default function CreditList({ customerId, selectedId, onSelect, reloadKey }) {
|
||||
const [filter, setFilter] = useState('open')
|
||||
const [rows, setRows] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setError(null)
|
||||
listCredits({ status: filter, customerId })
|
||||
.then(({ requests }) => { if (alive) setRows(requests || []) })
|
||||
.catch((err) => { if (alive) setError(err.message || 'Anträge konnten nicht geladen werden.') })
|
||||
return () => { alive = false }
|
||||
}, [filter, customerId, reloadKey])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="area-filter seg">
|
||||
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen</button>
|
||||
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
|
||||
</div>
|
||||
<Card pad={false}>
|
||||
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
|
||||
{!rows && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
|
||||
{rows && (
|
||||
<div className="area-rows">
|
||||
<div className="area-row credit-row head"><span>Eingang</span><span>Kunde</span><span>Leistung / bisher bei</span><span>Rechnung</span><span>Bezahlt bis</span><span>Status</span><span /></div>
|
||||
{rows.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</span></div>}
|
||||
{rows.map((r) => {
|
||||
const st = creditStatus(r.status)
|
||||
return (
|
||||
<div key={r.id} className={`area-row credit-row ${r.id === selectedId ? 'active' : ''}`}>
|
||||
<span>{fmtDateTime(r.createdAt)}<br /><span className="area-muted">{r.ref}</span></span>
|
||||
<span><strong>{r.customerName}</strong>{r.customerCode && <><br /><span className="area-muted">Kd.-Nr. {r.customerCode}</span></>}</span>
|
||||
<span>{r.services}<br /><span className="area-muted">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</span></span>
|
||||
<span>{formatCents(r.invoiceAmountCents)}<br /><span className="area-muted">für {r.billingMonths} Mon.</span></span>
|
||||
<span>{fmtDate(r.paidUntil)}</span>
|
||||
<span>
|
||||
<Badge tone={st.tone}>{st.label}</Badge>
|
||||
{r.status === 'genehmigt' && <><br /><span className="area-muted">{formatCents(r.amountCents)}{r.appliedAt ? '' : ' · nicht gebucht'}</span></>}
|
||||
</span>
|
||||
<span>
|
||||
<Button size="sm" variant="primary" onClick={() => onSelect(r.id)}>
|
||||
{['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import CreditList from './CreditList'
|
||||
import CreditDetail from './CreditDetail'
|
||||
|
||||
/**
|
||||
* Gutschriften: Liste und Antrag (Adresse ?antrag=<id>, andere Parameter wie ?tab= bleiben). Sammelliste unter
|
||||
* Kunden -> Gutschriften und Reiter "Gutschriften" auf der Kundenseite (mit customerId).
|
||||
*/
|
||||
export default function CreditsView({ customerId }) {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const selected = params.get('antrag') || ''
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
const select = (id) =>
|
||||
setParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (id) next.set('antrag', id)
|
||||
else next.delete('antrag')
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="credits-view">
|
||||
{selected && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
|
||||
</div>
|
||||
)}
|
||||
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { Button } from '../ui'
|
||||
import { createKundenansichtLink } from '../../lib/teamApi'
|
||||
import { openInNewTab } from '../../lib/openInNewTab'
|
||||
|
||||
/** Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut. */
|
||||
export default function KundenansichtButton({ customer, variant = 'primary' }) {
|
||||
/**
|
||||
* Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut.
|
||||
* checkAccess=false: Kunden-Dokument liegt nicht vor (z. B. Gutschrift-Detail), den Zugang prüft dann der Server (409).
|
||||
*/
|
||||
export default function KundenansichtButton({ customer, variant = 'primary', checkAccess = true }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const hasAccess = Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
||||
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
||||
|
||||
const open = async () => {
|
||||
setBusy(true)
|
||||
|
||||
Reference in new issue
Block a user