Compare commits
50
Commits
No files matched your search
@@ -7,8 +7,7 @@ export default function AdminRoute({ children }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center p-4" style={{ marginTop: 80 }}>
|
||||
<div className="spinner"></div>
|
||||
<div className="loading">
|
||||
<p>Wird geladen …</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
|
||||
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
|
||||
|
||||
const COLORS = { green: 'var(--green-fg)', red: 'var(--red-fg)', grey: 'var(--text-2)' }
|
||||
import { Badge, Button, Dialog, Field } from './ui'
|
||||
import { analyseColorTone } from './projectTones'
|
||||
|
||||
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
|
||||
export default function AnalyseRow({ projectId }) {
|
||||
@@ -54,80 +54,87 @@ export default function AnalyseRow({ projectId }) {
|
||||
} catch { /* Kopieren nicht möglich: Text bleibt markierbar */ }
|
||||
}
|
||||
const status = analyseStatus(data)
|
||||
const label = data ? textformLabel(data.avvVersion) : null
|
||||
const links = data ? legalLinks(data.avvVersion) : null
|
||||
const dateId = `analyse-datum-${projectId}`
|
||||
const errorLine = error ? <p className="msg err" role="alert">{error}</p> : null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="text-grey">Website-Analyse:</span>
|
||||
{loadError ? (
|
||||
<span style={{ color: COLORS.red }}>{loadError}</span>
|
||||
) : data ? (
|
||||
<span style={{ color: COLORS[status.color] }}>{status.text}</span>
|
||||
) : (
|
||||
<span className="text-grey">Lädt …</span>
|
||||
)}
|
||||
{status.hint && <span className="text-grey">({status.hint})</span>}
|
||||
{status.canEnable && !mode && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
|
||||
)}
|
||||
{status.canDisable && !mode && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setMode('off')}>Ausschalten</button>
|
||||
<>
|
||||
<div className="pv-block">
|
||||
<div className="pv-row">
|
||||
<span className="muted">Website-Analyse:</span>
|
||||
{loadError ? (
|
||||
<span className="msg err" role="alert">{loadError}</span>
|
||||
) : data ? (
|
||||
<Badge tone={analyseColorTone(status.color)} className="wrap">{status.text}</Badge>
|
||||
) : (
|
||||
<span className="muted">Lädt …</span>
|
||||
)}
|
||||
{status.hint && <span className="muted">({status.hint})</span>}
|
||||
{status.canEnable && mode !== 'off' && (
|
||||
<button type="button" className="link" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
|
||||
)}
|
||||
{status.canDisable && !mode && (
|
||||
<button type="button" className="link" onClick={() => setMode('off')}>Ausschalten</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'off' && (
|
||||
<div className="pv-row">
|
||||
<span>Website-Analyse wirklich ausschalten?</span>
|
||||
<button type="button" className="link-danger" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
|
||||
<button type="button" className="link" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode !== 'on' && errorLine}
|
||||
</div>
|
||||
|
||||
{mode === 'on' && data && (() => {
|
||||
const label = textformLabel(data.avvVersion)
|
||||
const links = legalLinks(data.avvVersion)
|
||||
return (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'flex-start' }}>
|
||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
{label.before}
|
||||
<a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
|
||||
{label.between}
|
||||
<a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
|
||||
{label.after}
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<label>
|
||||
Datum der Zustimmung:{' '}
|
||||
<input type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
|
||||
{mode === 'on' && data && (
|
||||
<Dialog
|
||||
title="Website-Analyse"
|
||||
onClose={cancel}
|
||||
busy={busy}
|
||||
footer={
|
||||
<>
|
||||
<span className="grow" />
|
||||
<Button type="button" disabled={busy} onClick={cancel}>Abbrechen</Button>
|
||||
<Button type="button" variant="primary" disabled={!canSubmit || busy} onClick={() => apply(true)}>Einschalten</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{errorLine}
|
||||
<label className="check top">
|
||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||
<span>
|
||||
{label.before}
|
||||
<a className="link" href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
|
||||
{label.between}
|
||||
<a className="link" href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
|
||||
{label.after}
|
||||
</span>
|
||||
</label>
|
||||
<Field label="Datum der Zustimmung:" htmlFor={dateId}>
|
||||
<input id={dateId} className="input" type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
<div className="field" role="radiogroup" aria-label="Weg der Zustimmung">
|
||||
<span className="strong">Weg:</span>
|
||||
{['chat', 'email'].map((c) => (
|
||||
<label key={c} className="check">
|
||||
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
|
||||
{channelLabel(c)}
|
||||
</label>
|
||||
<span role="radiogroup" aria-label="Weg der Zustimmung" style={{ display: 'inline-flex', gap: 10, alignItems: 'center' }}>
|
||||
<span>Weg:</span>
|
||||
{['chat', 'email'].map((c) => (
|
||||
<label key={c} style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
|
||||
{channelLabel(c)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
{privacyBlocked && <div style={{ color: COLORS.red }}>{privacyHint(data.privacy)}</div>}
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
|
||||
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{chatTemplate(data.avvVersion)}</p>
|
||||
<button type="button" className="btn btn-sm" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</button>
|
||||
</details>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-sm" disabled={!canSubmit || busy} onClick={() => apply(true)}>Einschalten</button>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{mode === 'off' && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span>Website-Analyse wirklich ausschalten?</span>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||
</div>
|
||||
{privacyBlocked && <p className="msg err">{privacyHint(data.privacy)}</p>}
|
||||
<details>
|
||||
<summary>Textvorlage für den Chat</summary>
|
||||
<p className="prose">{chatTemplate(data.avvVersion)}</p>
|
||||
<Button type="button" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</Button>
|
||||
</details>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{error && <div style={{ marginTop: 6, color: COLORS.red }}>{error}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { FaCodeBranch } from 'react-icons/fa'
|
||||
import { FaLinkSlash } from 'react-icons/fa6'
|
||||
import { Badge, Button } from './ui'
|
||||
import PreviewBlock from './PreviewBlock'
|
||||
import AnalyseRow from './AnalyseRow'
|
||||
import GiteaLinkButton from './GiteaLinkButton'
|
||||
import PreviewLinkButton from './PreviewLinkButton'
|
||||
import { statusValueTone } from './projectTones'
|
||||
import { suggestSubdomain } from '../lib/subdomain'
|
||||
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
|
||||
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
ready: 'var(--green-fg)',
|
||||
live: 'var(--green-fg)',
|
||||
online: 'var(--green-fg)',
|
||||
provisioning: 'var(--orange-fg)',
|
||||
pending: 'var(--orange-fg)',
|
||||
building: 'var(--blue-fg)',
|
||||
error: 'var(--red-fg)',
|
||||
failed: 'var(--red-fg)',
|
||||
}
|
||||
|
||||
function StatusBadge({ label, value }) {
|
||||
// Status und Provisioning: Wert als Badge, ohne Wert nichts
|
||||
function StatusValue({ label, value }) {
|
||||
if (!value) return null
|
||||
const color = STATUS_COLORS[String(value).toLowerCase()] || 'var(--text-2)'
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', marginRight: 12 }}>
|
||||
{label}:
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
return <span>{label}: <Badge tone={statusValueTone(value)}>{value}</Badge></span>
|
||||
}
|
||||
|
||||
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
|
||||
@@ -79,65 +60,47 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-subtle)', border: '1px solid var(--line)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="proj-item">
|
||||
<div className="proj-head">
|
||||
<div className="proj-main">
|
||||
<strong>{project.projectName}</strong>
|
||||
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
||||
{project.repoFullName && (
|
||||
<div className="text-grey" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
||||
</div>
|
||||
)}
|
||||
{project.subdomain && <>{' '}<span className="muted small">({project.subdomain})</span></>}
|
||||
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<div className="row-actions">
|
||||
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
||||
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||
{showActions && onUnassign && (
|
||||
<button
|
||||
type="button"
|
||||
title="Vom Ticket lösen"
|
||||
aria-label="Vom Ticket lösen"
|
||||
onClick={() => onUnassign(project.$id)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'var(--red-bg)', border: '1px solid var(--line)', color: 'var(--red-fg)', cursor: 'pointer' }}
|
||||
>
|
||||
<FaLinkSlash size={16} />
|
||||
</button>
|
||||
<Button variant="danger" type="button" title="Vom Ticket lösen" aria-label="Vom Ticket lösen" onClick={() => onUnassign(project.$id)}>
|
||||
Lösen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status- und Provisioning-Zeile */}
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<StatusBadge label="Status" value={project.status} />
|
||||
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
||||
</div>
|
||||
{(project.status || project.provisioningStatus) && (
|
||||
<div className="proj-meta">
|
||||
<StatusValue label="Status" value={project.status} />
|
||||
<StatusValue label="Provisioning" value={project.provisioningStatus} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
|
||||
|
||||
{showActions && project.subdomain && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
aria-expanded={showPreviewBlock}
|
||||
onClick={() => setShowPreviewBlock((v) => !v)}
|
||||
>
|
||||
<>
|
||||
<button type="button" className="link" aria-expanded={showPreviewBlock} onClick={() => setShowPreviewBlock((v) => !v)}>
|
||||
Preview {showPreviewBlock ? '▴' : '▾'}
|
||||
</button>
|
||||
{showPreviewBlock && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<PreviewBlock key={project.$id} projectId={project.$id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showPreviewBlock && <PreviewBlock key={project.$id} projectId={project.$id} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<>
|
||||
<div className="row-actions">
|
||||
{needsPreviewButton(project) && !showPreviewInput && (
|
||||
<button type="button" className="btn btn-sm" onClick={openPreviewInput}>
|
||||
<button type="button" className="link" onClick={openPreviewInput}>
|
||||
Preview einschalten
|
||||
</button>
|
||||
)}
|
||||
@@ -145,32 +108,23 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
style={{ maxWidth: 200 }}
|
||||
className="input"
|
||||
aria-label="Subdomain für die Preview"
|
||||
value={subdomain}
|
||||
onChange={(e) => setSubdomain(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={!subdomain || previewBusy}
|
||||
onClick={handleEnablePreview}
|
||||
>
|
||||
<Button type="button" disabled={!subdomain || previewBusy} onClick={handleEnablePreview}>
|
||||
Einschalten
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" disabled={pushBusy} onClick={handleBackfill}>
|
||||
<button type="button" className="link" disabled={pushBusy} onClick={handleBackfill}>
|
||||
Pushes nachtragen
|
||||
</button>
|
||||
</div>
|
||||
{(previewMessage || pushMessage) && (
|
||||
<div className="text-grey" style={{ marginTop: 6, fontSize: 12 }}>
|
||||
{previewMessage && <div>{previewMessage}</div>}
|
||||
{pushMessage && <div>{pushMessage}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{previewMessage && <p className="msg muted">{previewMessage}</p>}
|
||||
{pushMessage && <p className="msg muted">{pushMessage}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
|
||||
|
||||
export async function copyText(text) {
|
||||
try {
|
||||
@@ -53,26 +52,26 @@ export default function CopyableCredential({ value, secret = false, label = 'Wer
|
||||
{secret && (
|
||||
<button
|
||||
type="button"
|
||||
className="credential-action"
|
||||
className="link"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setVisible((current) => !current)
|
||||
}}
|
||||
title={visible ? `${label} ausblenden` : `${label} anzeigen`}
|
||||
aria-label={visible ? `${label} ausblenden` : `${label} anzeigen`}
|
||||
title={visible ? `${label} verbergen` : `${label} anzeigen`}
|
||||
aria-label={visible ? `${label} verbergen` : `${label} anzeigen`}
|
||||
>
|
||||
{visible ? <FaEyeSlash /> : <FaEye />}
|
||||
{visible ? 'Verbergen' : 'Anzeigen'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="credential-action"
|
||||
className="link"
|
||||
onClick={handleCopy}
|
||||
title={copied ? 'Kopiert' : `${label} kopieren`}
|
||||
title={copied ? `${label} kopiert` : `${label} kopieren`}
|
||||
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
|
||||
>
|
||||
{copied ? <FaCheck className="credential-check" /> : <FaCopy />}
|
||||
{copied ? 'Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
import { SiGitea } from 'react-icons/si'
|
||||
|
||||
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
|
||||
export default function GiteaLinkButton({ href, label = 'In Gitea öffnen' }) {
|
||||
if (!href) return null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
background: 'var(--violet-bg)',
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--violet-fg)',
|
||||
textDecoration: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SiGitea size={size} />
|
||||
<a className="link" href={href} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { modeOptionsFor, previewStatus, modeHint, isDevUnavailableError, NO_DEV_HINT } from '../lib/previewView'
|
||||
import { isValidEnvName, buildEnvPayload, isDevPreview, dotColor, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
|
||||
import { isValidEnvName, buildEnvPayload, isDevPreview, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
|
||||
import {
|
||||
fetchPreview,
|
||||
setPreviewMode,
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
stopPreviewDev,
|
||||
fetchPreviewLog,
|
||||
} from '../lib/previewApi'
|
||||
import { Button, Badge } from './ui'
|
||||
import { previewColorTone } from './projectTones'
|
||||
|
||||
const STATE_OF_COLOR = { green: 'online', yellow: 'building', red: 'failed', grey: 'off' }
|
||||
const NOT_ACTIVE = 'Preview-Runner ist nicht aktiv'
|
||||
|
||||
const logDate = (at) => {
|
||||
@@ -171,7 +172,7 @@ export default function PreviewBlock({ projectId }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-grey" style={{ fontSize: 12 }}>Lädt …</div>
|
||||
if (loading) return <p className="muted xs">Lädt …</p>
|
||||
|
||||
const view = data ? { ...data } : { state: 'off' }
|
||||
const status = previewStatus(view)
|
||||
@@ -182,18 +183,15 @@ export default function PreviewBlock({ projectId }) {
|
||||
const dev = isDevPreview(data)
|
||||
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
|
||||
|
||||
const small = { fontSize: 12 }
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<div className="pv-block">
|
||||
{data && (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<label className="text-grey" htmlFor={`pv-mode-${projectId}`} style={small}>Modus</label>
|
||||
<div className="pv-row">
|
||||
<label className="muted xs" htmlFor={`pv-mode-${projectId}`}>Modus</label>
|
||||
<select
|
||||
id={`pv-mode-${projectId}`}
|
||||
className="form-control"
|
||||
style={{ maxWidth: 200 }}
|
||||
className="select pv-mode"
|
||||
value={mode}
|
||||
disabled={busy || inactive}
|
||||
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
|
||||
@@ -201,47 +199,45 @@ export default function PreviewBlock({ projectId }) {
|
||||
{modeOptionsFor(data.devAvailable).map((o) => <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{data.devAvailable === false && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{NO_DEV_HINT}</div>}
|
||||
{hint && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{hint}</div>}
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dotColor(STATE_OF_COLOR[status.color]), display: 'inline-block', flexShrink: 0, marginTop: 4 }} />
|
||||
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
|
||||
{data.devAvailable === false && <p className="muted xs">{NO_DEV_HINT}</p>}
|
||||
{hint && <p className="muted xs">{hint}</p>}
|
||||
<div className="pv-row">
|
||||
<Badge tone={previewColorTone(status.color)} className="wrap">{status.text}</Badge>
|
||||
</div>
|
||||
{data.state === 'failed' && data.error && !isDevUnavailableError(data.error) && (
|
||||
<div style={{ color: 'var(--red-fg)', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
|
||||
<p className="msg err">{data.error}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{inactive && <div className="text-grey" style={{ ...small, marginTop: 8 }}>{NOT_ACTIVE}.</div>}
|
||||
{error && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
|
||||
{inactive && <p className="muted xs">{NOT_ACTIVE}.</p>}
|
||||
{error && <p className="msg err" role="alert">{error}</p>}
|
||||
|
||||
{data && !inactive && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
|
||||
<button type="button" className="btn btn-sm" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
|
||||
<div className="pv-row">
|
||||
<Button type="button" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
|
||||
Neu bauen
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
|
||||
</Button>
|
||||
<Button type="button" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
|
||||
Protokoll ansehen
|
||||
</button>
|
||||
</Button>
|
||||
{dev && (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
|
||||
<Button type="button" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
|
||||
Jetzt starten
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
|
||||
Stoppen
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ maxWidth: '100%' }}
|
||||
className="select"
|
||||
aria-label="Lauf wählen"
|
||||
value={logFile}
|
||||
disabled={busy}
|
||||
@@ -250,47 +246,39 @@ export default function PreviewBlock({ projectId }) {
|
||||
<option value="">Lauf wählen …</option>
|
||||
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
|
||||
</select>
|
||||
{logFile && (
|
||||
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'var(--bg-subtle)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{logText || '…'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{logFile && <pre className="pv-log">{logText || '…'}</pre>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<strong style={small}>Variablen</strong>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
|
||||
{names.map((n) => (
|
||||
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid var(--line)' }}>
|
||||
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
|
||||
<span className="text-grey">•••</span>
|
||||
{editName === n ? (
|
||||
<>
|
||||
<input className="form-control" style={{ flex: '1 1 120px', minWidth: 0 }} type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
|
||||
<button type="button" className="btn btn-sm" onClick={handleChange}>Übernehmen</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginTop: 8 }}>
|
||||
<input type="text" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
<input type="password" autoComplete="off" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleAdd}>Hinzufügen</button>
|
||||
</div>
|
||||
{envError && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 4 }}>{envError}</div>}
|
||||
{dirty && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
|
||||
</div>
|
||||
<h4 className="pv-title">Variablen</h4>
|
||||
{names.length === 0 && <p className="muted xs">Keine Variablen.</p>}
|
||||
{names.map((n) => (
|
||||
<div key={n} className="pv-var">
|
||||
<code>{n}</code>
|
||||
<span className="muted">•••</span>
|
||||
{editName === n ? (
|
||||
<>
|
||||
<input className="input" type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
|
||||
<button type="button" className="link" onClick={handleChange}>Übernehmen</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="link" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
|
||||
)}
|
||||
<div className="text-grey" style={{ ...small, marginTop: 6 }}>Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</div>
|
||||
<button type="button" className="link" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="pv-add">
|
||||
<input type="text" className="input" placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
<input type="password" autoComplete="off" className="input" placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
|
||||
<Button type="button" disabled={busy} onClick={handleAdd}>Hinzufügen</Button>
|
||||
</div>
|
||||
{envError && <p className="msg err">{envError}</p>}
|
||||
{dirty && (
|
||||
<div className="pv-row">
|
||||
<Button type="button" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted xs">Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { FaEye } from 'react-icons/fa6'
|
||||
import { createPreviewLink } from '../lib/teamApi'
|
||||
import { openInNewTab } from '../lib/openInNewTab'
|
||||
|
||||
// Mit subdomain holt der Knopf einen Einmal-Link vom Portal (Mitarbeiter sind dort nicht mehr angemeldet);
|
||||
// Mit subdomain holt der Link einen Einmal-Link vom Portal (Mitarbeiter sind dort nicht mehr angemeldet);
|
||||
// ohne subdomain bleibt er ein einfacher Link.
|
||||
export default function PreviewLinkButton({ href, subdomain, title = 'Preview oeffnen', size = 16 }) {
|
||||
export default function PreviewLinkButton({ href, subdomain, label = 'Vorschau öffnen' }) {
|
||||
if (!href) return null
|
||||
|
||||
const open = (event) => {
|
||||
@@ -16,28 +15,8 @@ export default function PreviewLinkButton({ href, subdomain, title = 'Preview oe
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={open}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
background: 'var(--green-bg)',
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--green-fg)',
|
||||
textDecoration: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<FaEye size={size} />
|
||||
<a className="link" href={href} onClick={open} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FaPlus, FaLink } from 'react-icons/fa6'
|
||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
|
||||
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
|
||||
@@ -7,6 +6,7 @@ import { slugify } from '../lib/subdomain'
|
||||
import AssignedProjectCard from './AssignedProjectCard'
|
||||
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
||||
import { kindLabelFor } from '../lib/assignFlow'
|
||||
import { Card, Button, Dialog } from './ui'
|
||||
|
||||
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
|
||||
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
|
||||
@@ -29,12 +29,18 @@ export default function TicketProjects({ ticket }) {
|
||||
fetchServices().then((s) => setKinds(s.kinds || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Neu laden (samt "Suche neue Repos ...") nur bei einem anderen Ticket, nicht bei Status, Zustaendig oder Dringend:
|
||||
// load haengt an der Ticket-ID, den jeweils neuesten Ticketstand (Kunde) liest es ueber ticketRef
|
||||
const ticketRef = useRef(ticket)
|
||||
ticketRef.current = ticket
|
||||
const ticketId = ticket?.$id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!ticket?.$id) return
|
||||
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
|
||||
if (!ticketId) return
|
||||
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticketId)])
|
||||
setAssigned(mine)
|
||||
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
|
||||
}, [ticket, fetchAllProjects, fetchByTicketId])
|
||||
setAvailable(assignableProjects(all, ticketRef.current, new Set(mine.map((p) => p.$id))))
|
||||
}, [ticketId, fetchAllProjects, fetchByTicketId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -156,16 +162,15 @@ export default function TicketProjects({ ticket }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card pad ticket-projects">
|
||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
|
||||
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>}
|
||||
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>}
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<Card title={`Projekte (${assigned.length})`}>
|
||||
{importing && <p className="muted">Suche neue Repos …</p>}
|
||||
{importHint && <p className="msg err" role="alert">{importHint}</p>}
|
||||
{error && <p className="msg err" role="alert">{error}</p>}
|
||||
{assigned.length === 0
|
||||
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
|
||||
? <p className="muted">Noch kein Projekt an diesem Ticket.</p>
|
||||
: assigned.map((p) => (
|
||||
<div key={p.$id}>
|
||||
<span className="muted" style={{ fontSize: 12 }} data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
|
||||
<span className="muted xs" data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
|
||||
<AssignedProjectCard
|
||||
project={p}
|
||||
showActions
|
||||
@@ -177,7 +182,7 @@ export default function TicketProjects({ ticket }) {
|
||||
))}
|
||||
|
||||
<div className="ticket-projects-assign">
|
||||
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<select className="select" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<option value="">Projekt wählen …</option>
|
||||
{available.map((p) => (
|
||||
<option key={p.$id} value={p.$id}>
|
||||
@@ -186,44 +191,43 @@ export default function TicketProjects({ ticket }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
|
||||
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
|
||||
<Button type="button" disabled={!selected || busy} onClick={assign}>Zuweisen</Button>
|
||||
<Button variant="quiet" type="button" onClick={() => setShowCreate((s) => !s)}>Neues Projekt</Button>
|
||||
</div>
|
||||
|
||||
{assignResult && (
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>
|
||||
<div>{assignResult.text}</div>
|
||||
<p className="msg">
|
||||
{assignResult.text}
|
||||
{assignResult.warnings.map((w, i) => (
|
||||
<div key={i} className="muted" style={{ marginLeft: 12 }}>{w}</div>
|
||||
<Fragment key={i}>
|
||||
<br />
|
||||
<span className="muted">{w}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
|
||||
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
|
||||
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
|
||||
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
|
||||
<form className="ticket-projects-assign" onSubmit={create}>
|
||||
<input className="input" aria-label="Anzeigename" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
|
||||
<input className="input" aria-label="Subdomain" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
|
||||
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{kindDialog && (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={() => setKindDialog(false)}>×</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Was ist das?</h2>
|
||||
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
|
||||
{kinds.length === 0 && (
|
||||
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{kinds.map((k) => (
|
||||
<button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<Dialog title="Was ist das?" onClose={() => setKindDialog(false)}>
|
||||
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
|
||||
{kinds.length === 0 && (
|
||||
<p className="msg err">Arten konnten nicht geladen werden – bitte neu laden.</p>
|
||||
)}
|
||||
<div className="row-buttons">
|
||||
{kinds.map((k) => (
|
||||
<Button key={k.key} type="button" onClick={() => assignWithKind(k.key)}>{k.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRotateRight } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
|
||||
import { loadChatComposer } from '../../lib/loadChatComposer'
|
||||
@@ -119,8 +118,8 @@ export default function ChatThread({ customerId, customerName }) {
|
||||
<div className="woms-chat" ref={zoneRef}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={() => setReloadKey((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
||||
<span className="msg err" role="alert">{error}</span>
|
||||
<Button onClick={() => setReloadKey((k) => k + 1)}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-messages" ref={boxRef} aria-live="polite" />
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button } from '../ui'
|
||||
import { Card, Badge, Button, Field } 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 { applyMessage, BILLING_MONTHS, calcLine, checkGlyph, checkTone, 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)
|
||||
|
||||
@@ -24,6 +21,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
const [calcText, setCalcText] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [busyAction, setBusyAction] = useState('')
|
||||
const busyRef = useRef(false)
|
||||
const amountTouched = useRef(false)
|
||||
const calcTimer = useRef(null)
|
||||
@@ -85,6 +83,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setBusyAction(action)
|
||||
try {
|
||||
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
|
||||
onChanged?.()
|
||||
@@ -94,6 +93,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
} finally {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setBusyAction('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setBusyAction('apply')
|
||||
try {
|
||||
const result = await applyCredit(id)
|
||||
onChanged?.()
|
||||
@@ -110,11 +111,12 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
} finally {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setBusyAction('')
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
if (error) return <Card><p className="msg err mb-2" role="alert">{error}</p><Button onClick={onClose}>Schließen</Button></Card>
|
||||
if (!d || !form) return <div className="loading">Wird geladen …</div>
|
||||
|
||||
const r = d.request
|
||||
const c = d.customer
|
||||
@@ -124,160 +126,176 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
||||
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
|
||||
const errorLine = actionError ? <p className="msg err" role="alert">{actionError}</p> : null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<Card title={`Antrag ${r.ref} · ${c.name || c.companyName}`} actions={<Button onClick={onClose}>Schließen</Button>}>
|
||||
<p className="muted">eingegangen {fmtDateTime(r.createdAt)} <Badge tone={st.tone}>{st.label}</Badge></p>
|
||||
{message && <p className="msg ok" role="status">{message}</p>}
|
||||
<div className="two-col">
|
||||
<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>
|
||||
<Card title="Antrag">
|
||||
<dl className="kv">
|
||||
<dt>Leistung</dt><dd>{r.services}</dd>
|
||||
<dt>Bisher bei</dt><dd>{r.provider}</dd>
|
||||
<dt>Domain</dt><dd>{r.domain ? <a className="link" 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>
|
||||
</Card>
|
||||
<Card title="Grund / Nachricht">
|
||||
<p className="prose">{r.message}</p>
|
||||
</Card>
|
||||
<Card title="Nachweise">
|
||||
{r.files.length ? (
|
||||
r.files.map((f) => (
|
||||
<div key={f.id} className="list-row">
|
||||
<div className="list-row-text">
|
||||
<a className="link" href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : <p className="muted">Keine Nachweise (mehr) vorhanden.</p>}
|
||||
</Card>
|
||||
<Card title="Verlauf">
|
||||
{r.history.map((h, i) => (
|
||||
<div key={i} className="list-row">
|
||||
<div className="list-row-text">
|
||||
<strong>{historyLabel(h.type)}</strong> <span className="muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
|
||||
{h.text && <p className="prose">{h.text}</p>}
|
||||
{(h.files || []).length > 0 && <div className="cell-sub">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<Card title="Kunde & Vertrag">
|
||||
<dl className="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><div className="cell-sub">Zusatzspeicher: {storageLabel(s.storageUnits)}</div></> : <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="toolbar">
|
||||
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} />
|
||||
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer">In Stripe öffnen</Button>}
|
||||
</div>
|
||||
</Card>
|
||||
{d.otherRequests.length > 0 && (
|
||||
<Card title="Weitere Anträge des Kunden">
|
||||
{d.otherRequests.map((o) => {
|
||||
const ost = creditStatus(o.status)
|
||||
return (
|
||||
<div key={o.id} className="list-row">
|
||||
<div className="list-row-text">
|
||||
<button type="button" className="link" onClick={() => onOpen?.(o.id)}>{o.ref}</button> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
)}
|
||||
<Card title="Automatische Prüfung">
|
||||
{d.autoChecks.map((k) => (
|
||||
<div key={k.key} className="list-row">
|
||||
<div className="list-row-text">
|
||||
<Badge tone={checkTone(k.status)}>{checkGlyph(k.status)}</Badge> {k.label}{k.detail && <div className="cell-sub">{k.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
{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>
|
||||
<Card title="Prüfung durch dich">
|
||||
<div className="field">
|
||||
{d.requiredChecks.map((k) => (
|
||||
<label key={k.key} className="check">
|
||||
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Berechnung">
|
||||
<div className="form-stack">
|
||||
<div className="field-row">
|
||||
<Field label="Rechnungsbetrag (€)" htmlFor="calc-amount">
|
||||
<input id="calc-amount" className="input" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Rechnung gilt für" htmlFor="calc-months">
|
||||
<select id="calc-months" className="select" 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>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<Field label="Bezahlt bis" htmlFor="calc-paid-until">
|
||||
<input id="calc-paid-until" className="input" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Übernahme ab" htmlFor="calc-from">
|
||||
<input id="calc-from" className="input" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div>
|
||||
<p className="strong">{calcText}</p>
|
||||
<p className="muted small">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
|
||||
</div>
|
||||
<Field label="Gutschrift (€)" htmlFor="credit-amount">
|
||||
<input id="credit-amount" className="input" type="number" step="0.01" min="0.01" value={form.amount}
|
||||
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
|
||||
</Field>
|
||||
<Field label="Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)" htmlFor="credit-note">
|
||||
<textarea id="credit-note" className="textarea" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||
</Field>
|
||||
<div className="flex items-center gap-3 wrap">
|
||||
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>{busyAction === 'approve' ? 'Speichert …' : 'Genehmigen & gutschreiben'}</Button>
|
||||
<Button onClick={() => decide('question')} disabled={busy}>{busyAction === 'question' ? 'Sendet …' : 'Rückfrage senden'}</Button>
|
||||
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>{busyAction === 'reject' ? 'Speichert …' : 'Ablehnen'}</Button>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
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>
|
||||
{errorLine}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4>Entscheidung</h4>
|
||||
<Card title="Entscheidung">
|
||||
{r.status === 'genehmigt' ? (
|
||||
<>
|
||||
<dl className="area-kv">
|
||||
<dl className="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></>}
|
||||
{r.decisionNote && <><dt>Nachricht</dt><dd className="prose">{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>
|
||||
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="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>}
|
||||
{!r.appliedAt && <div className="toolbar"><Button variant="primary" onClick={apply} disabled={busy}>{busyAction === 'apply' ? 'Speichert …' : 'Jetzt in Stripe gutschreiben'}</Button></div>}
|
||||
</>
|
||||
) : (
|
||||
<dl className="area-kv">
|
||||
<dl className="kv">
|
||||
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
||||
<dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd>
|
||||
<dt>Begründung</dt><dd className="prose">{r.decisionNote}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</>
|
||||
{errorLine}
|
||||
</Card>
|
||||
)}
|
||||
{actionError && <div className="area-error">{actionError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button } from '../ui'
|
||||
import { Badge, EmptyState, Tabs } from '../ui'
|
||||
import { listCredits } from '../../lib/teamApi'
|
||||
import { creditStatus } from '../../lib/creditFormat'
|
||||
import { creditActionLabel, creditStatus, creditStatusNote } 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 }) {
|
||||
@@ -23,41 +21,52 @@ export default function CreditList({ customerId, selectedId, onSelect, 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>}
|
||||
<Tabs tabs={[{ id: 'open', label: 'Offen' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
{!rows && !error && <div className="loading">Wird geladen …</div>}
|
||||
{rows && rows.length === 0 && (
|
||||
<EmptyState title={filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'} />
|
||||
)}
|
||||
{rows && rows.length > 0 && (
|
||||
<table className="table table-wrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="hide-sm">Eingang</th>
|
||||
<th className="col-name">Kunde</th>
|
||||
<th className="hide-md">Leistung / bisher bei</th>
|
||||
<th className="hide-sm right num">Rechnung</th>
|
||||
<th className="hide-md">Bezahlt bis</th>
|
||||
<th>Status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const st = creditStatus(r.status)
|
||||
const note = creditStatusNote(r)
|
||||
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>
|
||||
<tr key={r.id} aria-current={r.id === selectedId ? 'true' : undefined}>
|
||||
<td className="hide-sm">{fmtDateTime(r.createdAt)}<div className="cell-sub">{r.ref}</div></td>
|
||||
<td>
|
||||
<div className="cell-title">{r.customerName}</div>
|
||||
{r.customerCode && <div className="cell-sub">Kd.-Nr. {r.customerCode}</div>}
|
||||
</td>
|
||||
<td className="hide-md">{r.services}<div className="cell-sub">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</div></td>
|
||||
<td className="hide-sm right num">{formatCents(r.invoiceAmountCents)}<div className="cell-sub">für {r.billingMonths} Mon.</div></td>
|
||||
<td className="hide-md">{fmtDate(r.paidUntil)}</td>
|
||||
<td>
|
||||
<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>
|
||||
{note && <div className="cell-sub">{note}</div>}
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<button type="button" className="link" onClick={() => onSelect(r.id)}>{creditActionLabel(r.status)}</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,11 +23,11 @@ export default function CreditsView({ customerId }) {
|
||||
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>
|
||||
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
|
||||
)}
|
||||
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
|
||||
<div className={selected ? 'mt-3' : undefined}>
|
||||
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge } from '../ui'
|
||||
import { getCustomerAboStatus } from '../../lib/teamApi'
|
||||
import { fmtDate, productLabelOf } from '../../lib/format'
|
||||
import { storageLabel } from '../../lib/storageFormat'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
|
||||
export default function AboStatusCard({ customerId }) {
|
||||
@@ -23,13 +21,13 @@ export default function AboStatusCard({ customerId }) {
|
||||
|
||||
return (
|
||||
<Card title="Abo">
|
||||
{error && <div className="area-error">{error}</div>}
|
||||
{!abo && !error && <FaSpinner className="spinner" />}
|
||||
{abo && !abo.status && <span className="area-muted">kein Abo</span>}
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
{!abo && !error && <div className="loading">Wird geladen …</div>}
|
||||
{abo && !abo.status && <p className="muted">kein Abo</p>}
|
||||
{abo && abo.status && (
|
||||
<dl className="area-kv">
|
||||
<dl className="kv">
|
||||
<dt>Abo</dt>
|
||||
<dd className="area-inline">
|
||||
<dd className="flex items-center gap-2 wrap">
|
||||
{productLabelOf(abo.productKey)}
|
||||
<Badge tone={abo.active ? 'ok' : 'danger'}>{abo.active ? 'aktiv' : abo.status}</Badge>
|
||||
</dd>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button, EmptyState } from '../ui'
|
||||
import { copyText } from '../CopyableCredential'
|
||||
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
|
||||
@@ -11,9 +10,9 @@ import {
|
||||
} from '../../lib/funktionenView'
|
||||
import { CHANNELS, channelLabel } from '../../lib/analyseView'
|
||||
import { berlinYmd, fmtDateTime } from '../../lib/format'
|
||||
import { ticketPath } from '../../lib/ticketRoutes'
|
||||
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
|
||||
import ZeitenPanel from './ZeitenPanel'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
// Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
|
||||
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
|
||||
@@ -30,13 +29,13 @@ function SiteKey({ value }) {
|
||||
timer.current = setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
return (
|
||||
<span className="funktionen-sitekey">
|
||||
<span className="area-muted">Site-Key</span>
|
||||
<div className="funktionen-sitekey">
|
||||
<span className="muted">Site-Key</span>
|
||||
<code>{value}</code>
|
||||
<button type="button" className="funktionen-sitekey-copy" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
|
||||
{copied ? <FaCheck className="funktionen-sitekey-ok" /> : <FaCopy />}
|
||||
<button type="button" className="link" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
|
||||
{copied ? 'Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,8 +51,8 @@ function KopierKnopf({ text, label }) {
|
||||
timer.current = setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
return (
|
||||
<Button size="sm" variant="ghost" onClick={copy} title={label}>
|
||||
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>}
|
||||
<Button type="button" onClick={copy}>
|
||||
{copied ? 'Kopiert' : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -78,31 +77,31 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
|
||||
const hindernis = hindernisText(feature.hindernis)
|
||||
if (fehler || !antwort) {
|
||||
return (
|
||||
<div className="area-row funktionen-panel">
|
||||
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />}
|
||||
{hindernis && <div className="area-summary warn">{hindernis}</div>}
|
||||
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>}
|
||||
<div className="funktionen-panel">
|
||||
{fehler ? <div className="msg err" role="alert">{fehler}</div> : <div className="muted">Wird geladen …</div>}
|
||||
{hindernis && <div className="msg warn">{hindernis}</div>}
|
||||
{fehler && <div><Button type="button" onClick={() => setRetry((n) => n + 1)}>Erneut laden</Button></div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const modell = panelModell(antwort, feature)
|
||||
return (
|
||||
<div className="area-row funktionen-panel">
|
||||
<div className="funktionen-panel">
|
||||
<div className="funktionen-panel-head">
|
||||
<strong>Einbauanleitung</strong>
|
||||
{modell.kopf && <span className="area-muted">{modell.kopf}</span>}
|
||||
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>}
|
||||
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button>
|
||||
{modell.kopf && <span className="muted small">{modell.kopf}</span>}
|
||||
{antwort.ticketWoid && <Link className="link" to={ticketPath(antwort.ticketWoid)}>Zum Ticket</Link>}
|
||||
<button type="button" className="link" onClick={() => setRetry((n) => n + 1)}>Anleitung neu laden</button>
|
||||
</div>
|
||||
{modell.warnungen.length > 0 && (
|
||||
<ul className="area-error funktionen-panel-warnungen">
|
||||
<ul className="msg err funktionen-panel-warnungen">
|
||||
{modell.warnungen.map((text, i) => <li key={i}>{text}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>}
|
||||
{modell.hinweis && <div className="msg warn">{modell.hinweis}</div>}
|
||||
{modell.kopien.length > 0 && (
|
||||
<div className="area-inline">
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
{modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)}
|
||||
</div>
|
||||
)}
|
||||
@@ -122,26 +121,26 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
|
||||
const ansicht = textformAnsicht(feld, funktion)
|
||||
const { label, links, vorlage } = ansicht
|
||||
return (
|
||||
<div className="area-row funktionen-panel">
|
||||
<label className="area-inline" style={{ alignItems: 'flex-start', flexWrap: 'nowrap' }}>
|
||||
<input type="checkbox" style={{ marginTop: 3 }} checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
|
||||
<div className="funktionen-panel">
|
||||
<label className="funktionen-zustimmung">
|
||||
<input type="checkbox" checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
|
||||
<span>
|
||||
{label.before}
|
||||
{links.lb ? <a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb}
|
||||
{links.lb ? <a className="link" href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb}
|
||||
{label.between}
|
||||
{links.avv ? <a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv}
|
||||
{links.avv ? <a className="link" href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv}
|
||||
{label.after}
|
||||
</span>
|
||||
</label>
|
||||
<div className="area-inline">
|
||||
<label>
|
||||
Datum der Zustimmung:{' '}
|
||||
<input type="date" required className="form-control" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} />
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<label className="flex items-center gap-2 wrap">
|
||||
Datum der Zustimmung:
|
||||
<input type="date" required className="input" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} />
|
||||
</label>
|
||||
<span role="radiogroup" aria-label="Weg der Zustimmung" className="area-inline">
|
||||
<span role="radiogroup" aria-label="Weg der Zustimmung" className="flex items-center gap-2 wrap">
|
||||
<span>Weg:</span>
|
||||
{CHANNELS.map((c) => (
|
||||
<label key={c} className="area-inline">
|
||||
<label key={c} className="check">
|
||||
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
|
||||
{channelLabel(c)}
|
||||
</label>
|
||||
@@ -149,13 +148,13 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
|
||||
</span>
|
||||
</div>
|
||||
{vorlage ? (
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
|
||||
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{vorlage}</p>
|
||||
<details className="funktionen-vorlage">
|
||||
<summary>Textvorlage für den Chat</summary>
|
||||
<p>{vorlage}</p>
|
||||
<KopierKnopf text={vorlage} label="Vorlage kopieren" />
|
||||
</details>
|
||||
) : (
|
||||
<div className="area-summary warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div>
|
||||
<div className="msg warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -190,7 +189,7 @@ function ShopPanel({ customerId, shop, onDecided }) {
|
||||
}
|
||||
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
|
||||
return (
|
||||
<div className="area-row funktionen-panel">
|
||||
<div className="funktionen-shop">
|
||||
<div className="funktionen-panel-head">
|
||||
<strong>Webshop: {shop.projectName}</strong>
|
||||
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
|
||||
@@ -198,34 +197,34 @@ function ShopPanel({ customerId, shop, onDecided }) {
|
||||
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
|
||||
</div>
|
||||
{a && (
|
||||
<dl className="area-kv">
|
||||
<dl className="kv">
|
||||
{anfrageZeilen(a.anfrage).map((z) => (
|
||||
<Fragment key={z.label}><dt>{z.label}</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{z.text}</dd></Fragment>
|
||||
<Fragment key={z.label}><dt>{z.label}</dt><dd>{z.text}</dd></Fragment>
|
||||
))}
|
||||
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
|
||||
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
|
||||
{a.begruendung && <><dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{a.begruendung}</dd></>}
|
||||
{a.begruendung && <><dt>Begründung</dt><dd>{a.begruendung}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
<div className="area-muted">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
|
||||
<div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
|
||||
{a?.status === 'offen' && (
|
||||
frage ? (
|
||||
<div className="area-inline">
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
|
||||
{frage === 'ablehnen' && (
|
||||
<textarea className="form-control" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} style={{ width: '100%' }} />
|
||||
<textarea className="textarea funktionen-begruendung" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" aria-label="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} />
|
||||
)}
|
||||
<Button size="sm" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
|
||||
<Button type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
|
||||
<Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="area-inline">
|
||||
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<Button type="button" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
|
||||
<Button type="button" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{status && <div className={status.ok ? 'area-message' : 'area-error'}>{status.text}</div>}
|
||||
{status && <div className={`msg ${status.ok ? 'ok' : 'err'}`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -296,97 +295,122 @@ export default function CustomerFunktionen({ customerId }) {
|
||||
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
|
||||
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
|
||||
|
||||
if (error) return <Card><div className="area-error">{error}</div></Card>
|
||||
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
|
||||
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
|
||||
if (error) return <div className="msg err" role="alert">{error}</div>
|
||||
if (!projects) return <div className="loading">Wird geladen …</div>
|
||||
if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
|
||||
|
||||
const shopEintraege = shop.filter(shopHatEintrag)
|
||||
return (
|
||||
<Card title="Funktionen" pad={false}>
|
||||
<div className="area-rows">
|
||||
<div className="area-row funktionen-row head"><span>Projekt</span><span>Funktion</span><span>Status</span><span>Ticket</span><span>Aktionen</span></div>
|
||||
{projects.flatMap((p) => FUNKTIONEN.map((f, index) => {
|
||||
const feature = funktionOf(p, f.key)
|
||||
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
|
||||
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
||||
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
|
||||
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
||||
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
|
||||
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
|
||||
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
|
||||
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
|
||||
return (
|
||||
<Fragment key={`${p.projectId}:${f.key}`}>
|
||||
<div className="area-row funktionen-row">
|
||||
{index === 0 ? (
|
||||
<span>
|
||||
<strong>{p.projectName}</strong>
|
||||
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="funktionen-leer" />
|
||||
)}
|
||||
<span>{f.label}</span>
|
||||
<span>
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
|
||||
{zeitenText && <><br /><span className={feature.hindernis ? 'area-warn' : 'area-muted'}>{zeitenText}</span></>}
|
||||
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
|
||||
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
|
||||
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
|
||||
</span>
|
||||
<span>
|
||||
{feature.ticketMissing
|
||||
? <Badge tone="danger">Ticket fehlt</Badge>
|
||||
: feature.ticketWoid
|
||||
? <Link to={`/tickets?woid=${encodeURIComponent(feature.ticketWoid)}`}>#{feature.ticketWoid}</Link>
|
||||
: '–'}
|
||||
</span>
|
||||
<span className="area-inline">
|
||||
{isConfirming ? (
|
||||
<Fragment>
|
||||
<span>{f.bestaetigen[confirm.action]}</span>
|
||||
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} />
|
||||
<Button size="sm" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
|
||||
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
|
||||
{AKTION_LABEL[action]}
|
||||
</Button>
|
||||
))}
|
||||
{hatAnleitung && (
|
||||
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
|
||||
Anleitung
|
||||
</Button>
|
||||
<Card title="Funktionen">
|
||||
<table className="table funktionen-table">
|
||||
<thead>
|
||||
<tr><th>Funktion</th><th>Status</th><th>Ticket</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
{projects.map((p) => (
|
||||
<tbody key={p.projectId}>
|
||||
<tr className="funktionen-group">
|
||||
<th colSpan={4} scope="rowgroup">
|
||||
{p.projectName}
|
||||
{p.liveUrl && <>{' '}<a className="link" href={p.liveUrl} target="_blank" rel="noreferrer">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
|
||||
</th>
|
||||
</tr>
|
||||
{FUNKTIONEN.map((f) => {
|
||||
const feature = funktionOf(p, f.key)
|
||||
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
|
||||
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
||||
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
|
||||
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
||||
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
|
||||
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
|
||||
const siteKey = siteKeyOf(feature, f.key)
|
||||
const hindernis = feature.state === 'setup' ? hindernisText(feature.hindernis) : ''
|
||||
const abgeschaltet = abgeschaltetText(feature)
|
||||
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
|
||||
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
|
||||
// Zusatzzeilen direkt unter der Funktionszeile in dieser Reihenfolge: Bestaetigung, Zustimmung in Textform, Einbauanleitung.
|
||||
// Alle Zeilen bis auf die letzte verlieren die Haarlinie, damit sie als eine Gruppe lesbar sind (.funktionen-offen).
|
||||
const letzte = [isConfirming, braucheTextform, anleitungOffenHier, hatZeitenPanel].lastIndexOf(true)
|
||||
const offen = (nr) => nr < letzte
|
||||
return (
|
||||
<Fragment key={f.key}>
|
||||
<tr className={`funktionen-zeile${offen(-1) ? ' funktionen-offen' : ''}`}>
|
||||
<td>{f.label}</td>
|
||||
<td>
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
{hindernis && <div className="cell-sub msg warn">{hindernis}</div>}
|
||||
{zeitenText && <div className={feature.hindernis ? 'cell-sub msg warn' : 'cell-sub'}>{zeitenText}</div>}
|
||||
{abgeschaltet && <div className="cell-sub">{abgeschaltet}</div>}
|
||||
{feature.adminNote && <div className="cell-sub" title={feature.adminNote}>{short(feature.adminNote)}</div>}
|
||||
{siteKey && <SiteKey value={siteKey} />}
|
||||
</td>
|
||||
<td className={feature.ticketMissing || feature.ticketWoid ? undefined : 'funktionen-ohne-ticket'}>
|
||||
{feature.ticketMissing
|
||||
? <Badge tone="danger">Ticket fehlt</Badge>
|
||||
: feature.ticketWoid
|
||||
? <Link className="link" to={ticketPath(feature.ticketWoid)}>#{feature.ticketWoid}</Link>
|
||||
: <span className="faint">–</span>}
|
||||
</td>
|
||||
<td>
|
||||
{!isConfirming && (
|
||||
<div className="funktionen-actions">
|
||||
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
|
||||
<button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
|
||||
{AKTION_LABEL[action]}
|
||||
</button>
|
||||
))}
|
||||
{hatAnleitung && (
|
||||
<button type="button" className="link" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
|
||||
Anleitung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</td>
|
||||
</tr>
|
||||
{isConfirming && (
|
||||
<tr className={`funktionen-confirm${offen(0) ? ' funktionen-offen' : ''}`}>
|
||||
<td colSpan={4}>
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span>{f.bestaetigen[confirm.action]}</span>
|
||||
<input className="input funktionen-note" placeholder="Notiz (optional)" maxLength={120} aria-label="Notiz (optional)" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
<Button type="button" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
|
||||
<Button type="button" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{braucheTextform && (
|
||||
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
|
||||
)}
|
||||
{hatZeitenPanel && (
|
||||
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
|
||||
)}
|
||||
{anleitungOffenHier && (
|
||||
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}))}
|
||||
</div>
|
||||
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
|
||||
{shopFehler && <div className="area-error" style={{ margin: 12 }}>{shopFehler}</div>}
|
||||
{shop.filter(shopHatEintrag).length > 0 && (
|
||||
<div className="area-rows">
|
||||
{shop.filter(shopHatEintrag).map((s) => (
|
||||
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{braucheTextform && (
|
||||
<tr className={`funktionen-extra${offen(1) ? ' funktionen-offen' : ''}`}>
|
||||
<td colSpan={4}>
|
||||
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{anleitungOffenHier && (
|
||||
<tr className={`funktionen-extra${offen(2) ? ' funktionen-offen' : ''}`}>
|
||||
<td colSpan={4}>
|
||||
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{hatZeitenPanel && (
|
||||
<tr className="funktionen-extra">
|
||||
<td colSpan={4}>
|
||||
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
{status && <div className={`msg ${status.ok ? 'ok' : 'err'} funktionen-msg`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
|
||||
{shopFehler && <div className="msg err funktionen-msg" role="alert">{shopFehler}</div>}
|
||||
{shopEintraege.map((s) => (
|
||||
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
|
||||
import { Card, Button, EmptyState } from '../ui'
|
||||
import { Button, EmptyState } from '../ui'
|
||||
import { getCustomerViewLog } from '../../lib/teamApi'
|
||||
import { formatViewLogTime, viewLogLabel } from '../../lib/viewLog'
|
||||
|
||||
@@ -24,33 +23,38 @@ export default function CustomerViewLog({ customerId }) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<div className="msg err" role="alert">{error}</div>
|
||||
<Button onClick={load}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!entries) return <Card><FaSpinner className="spinner" /></Card>
|
||||
if (!entries) return <div className="loading">Wird geladen …</div>
|
||||
if (entries.length === 0) {
|
||||
return <Card><EmptyState title="Noch keine Einträge" hint="Hier steht, wer die Kundenansicht geöffnet oder darin etwas geändert hat." /></Card>
|
||||
return <EmptyState title="Noch keine Einträge" hint="Hier steht, wer die Kundenansicht geöffnet oder darin etwas geändert hat." />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<div className="list">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Mitarbeiter</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="list-row">
|
||||
<span className="mono muted list-row-text" style={{ minWidth: 130 }}>{formatViewLogTime(e.at)}</span>
|
||||
<span className="list-row-text" style={{ minWidth: 120, fontWeight: 600 }}>{e.staffName || '–'}</span>
|
||||
<div className="grow list-row-text">
|
||||
<tr key={e.id}>
|
||||
<td className="nowrap num">{formatViewLogTime(e.at)}</td>
|
||||
<td>{e.staffName || '–'}</td>
|
||||
<td>
|
||||
<div>{viewLogLabel(e.action)}</div>
|
||||
{e.detail && <div className="muted">{e.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{e.detail && <div className="cell-sub">{e.detail}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { FaArrowUpRightFromSquare, FaSpinner } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { createKundenansichtLink } from '../../lib/teamApi'
|
||||
import { openInNewTab } from '../../lib/openInNewTab'
|
||||
@@ -8,7 +7,7 @@ import { openInNewTab } from '../../lib/openInNewTab'
|
||||
* 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 }) {
|
||||
export default function KundenansichtButton({ customer, variant = 'default', checkAccess = true }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
||||
|
||||
@@ -30,7 +29,7 @@ export default function KundenansichtButton({ customer, variant = 'primary', che
|
||||
disabled={!hasAccess || busy}
|
||||
title={hasAccess ? 'Portal so öffnen, wie der Kunde es sieht' : 'Kein Portal-Zugang'}
|
||||
>
|
||||
{busy ? <FaSpinner className="spinner" /> : <FaArrowUpRightFromSquare />} Kundenansicht öffnen
|
||||
{busy ? 'Wird geladen …' : 'Kundenansicht öffnen'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaCopy, FaSpinner } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
|
||||
import {
|
||||
@@ -9,8 +8,8 @@ import {
|
||||
|
||||
// Panel "Öffnungszeiten live" unter der Zeile im Reiter "Funktionen": Team-Einstellungen der Website-Leisten (nur das Team ändert sie)
|
||||
// und "Ersatz-HTML kopieren" je Plan. Entscheidungen, Texte und Prüfungen stehen in zeitenView (rein, getestet); hier wird nur verdrahtet.
|
||||
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (area-row funktionen-panel) liegt als eigene Zeile außerhalb
|
||||
// des Fünf-Spalten-Rasters, wie die Einbauanleitung des Terminplaners.
|
||||
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (funktionen-panel) steht in einer eigenen Tabellenzeile
|
||||
// unter der Funktionszeile, wie die Einbauanleitung des Terminplaners; weißes Design: keine Icons, Bausteine aus ../ui.
|
||||
export default function ZeitenPanel({ customerId, projectId, feature, onSaved, onReload }) {
|
||||
const start = teamFormular(feature)
|
||||
const [form, setForm] = useState(start)
|
||||
@@ -62,17 +61,17 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
|
||||
const knoepfe = ersatzKnoepfe(feature)
|
||||
|
||||
return (
|
||||
<div className="area-row funktionen-panel funktionen-panel-zeiten">
|
||||
<div className="funktionen-panel funktionen-panel-zeiten">
|
||||
<div className="funktionen-panel-head">
|
||||
<strong>Öffnungszeiten live</strong>
|
||||
<span className="area-muted">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span>
|
||||
<span className="muted small">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={speichern} className="funktionen-panel-form">
|
||||
<label className="funktionen-panel-feld">
|
||||
<span>Header-Selektor</span>
|
||||
<input
|
||||
className="form-control"
|
||||
className="input"
|
||||
value={form.navSelektor}
|
||||
onChange={(e) => aendern({ navSelektor: e.target.value })}
|
||||
disabled={busy}
|
||||
@@ -82,51 +81,51 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
|
||||
aria-invalid={selektorFehler ? 'true' : 'false'}
|
||||
/>
|
||||
</label>
|
||||
<div className="area-muted">
|
||||
<div className="muted small">
|
||||
Nur nötig, wenn die Hinweis-Leiste den festen Header der Website überlappt: CSS-Selektor des Headers.
|
||||
</div>
|
||||
{selektorFehler && <div className="area-error" role="alert">{selektorFehler}</div>}
|
||||
<div className="area-inline">
|
||||
<label className="area-inline">
|
||||
{selektorFehler && <div className="msg err" role="alert">{selektorFehler}</div>}
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
|
||||
Kontakt-Leiste aus
|
||||
</label>
|
||||
<label className="area-inline">
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
|
||||
Hinweis-Leiste aus
|
||||
</label>
|
||||
</div>
|
||||
<div className="area-inline">
|
||||
<Button type="submit" size="sm" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
|
||||
{busy ? <FaSpinner className="spinner" /> : 'Speichern'}
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<Button type="submit" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
|
||||
{busy ? 'Speichert …' : 'Speichern'}
|
||||
</Button>
|
||||
{meldung && <span role="status" className={meldung.ok ? 'area-message funktionen-panel-meldung' : 'area-error funktionen-panel-meldung'}>{meldung.text}</span>}
|
||||
{meldung && <span role="status" className={meldung.ok ? 'msg ok' : 'msg err'}>{meldung.text}</span>}
|
||||
{meldung?.neuLaden && (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button>
|
||||
<Button type="button" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="funktionen-panel-head">
|
||||
<strong>Ersatz-HTML</strong>
|
||||
<span className="area-muted">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span>
|
||||
<span className="muted small">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span>
|
||||
</div>
|
||||
{knoepfe.length === 0 ? (
|
||||
<div className="area-muted">{KEINE_ZEITEN_TEXT}</div>
|
||||
<div className="muted small">{KEINE_ZEITEN_TEXT}</div>
|
||||
) : (
|
||||
<div className="area-inline">
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
{knoepfe.map((k) => (
|
||||
<Button key={k.kurz} type="button" size="sm" variant="ghost" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
|
||||
{ersatz.busy && ersatz.kurz === k.kurz ? <FaSpinner className="spinner" /> : <FaCopy />} {k.label}
|
||||
<Button key={k.kurz} type="button" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
|
||||
{ersatz.busy && ersatz.kurz === k.kurz ? 'Lädt …' : k.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'area-message' : 'area-error'}>{ersatz.meldung.text}</div>}
|
||||
{ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'msg ok' : 'msg err'}>{ersatz.meldung.text}</div>}
|
||||
{ersatz.html && (
|
||||
<textarea
|
||||
readOnly
|
||||
className="form-control funktionen-panel-html"
|
||||
className="textarea funktionen-panel-html"
|
||||
rows={8}
|
||||
value={ersatz.html}
|
||||
aria-label="Ersatz-HTML zum Kopieren"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FaTriangleExclamation } from 'react-icons/fa6'
|
||||
import { Badge, Card, EmptyState } from '../ui'
|
||||
import { Badge, Card, EmptyState, Stat } from '../ui'
|
||||
import { aboView } from '../../lib/financeView'
|
||||
import { isNegText } from '../../lib/financeUi'
|
||||
|
||||
/**
|
||||
* Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
|
||||
@@ -12,50 +12,45 @@ export default function AboCard({ abos }) {
|
||||
|
||||
if (v.error) {
|
||||
return (
|
||||
<Card title="Abos" className="fin-abo-card">
|
||||
<p className="fin-hint fin-hint-warn" role="alert">
|
||||
<FaTriangleExclamation aria-hidden="true" /> {v.error}
|
||||
</p>
|
||||
<Card title="Abos">
|
||||
<div className="fin-body">
|
||||
<p className="msg warn" role="alert">
|
||||
{v.error}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Abos" className="fin-abo-card" bodyStyle={{ padding: 0 }}>
|
||||
<div className="fin-stats">
|
||||
<span className="fin-amount fin-stat-value">{v.monthlyText}</span>
|
||||
<span className="fin-stat-label">Abo-Einnahmen pro Monat</span>
|
||||
<span className="fin-amount fin-stat-value">{v.payingText}</span>
|
||||
<span className="fin-stat-label">zahlende Kunden</span>
|
||||
{v.atRiskText && (
|
||||
<>
|
||||
<span className="fin-amount fin-stat-value fin-flag">{v.atRiskText}</span>
|
||||
<span className="fin-stat-label fin-flag">
|
||||
<FaTriangleExclamation aria-hidden="true" /> gefährdet
|
||||
</span>
|
||||
</>
|
||||
<Card title="Abos">
|
||||
<div className="fin-body">
|
||||
<div className="stats fin-abo-stats">
|
||||
<Stat label="Abo-Einnahmen pro Monat" value={v.monthlyText} tone={isNegText(v.monthlyText) ? 'neg' : ''} />
|
||||
<Stat label="zahlende Kunden" value={v.payingText} />
|
||||
{v.atRiskText && <Stat label="gefährdet" value={v.atRiskText} tone="neg" />}
|
||||
</div>
|
||||
|
||||
{v.rows.length === 0 ? (
|
||||
<EmptyState title="Noch keine Abos." />
|
||||
) : (
|
||||
<ul className="fin-abos">
|
||||
{v.rows.map((row) => (
|
||||
<li key={row.key} className="fin-abo">
|
||||
<div className="fin-abo-main">
|
||||
<span className="fin-abo-customer">{row.customer}</span>
|
||||
<span className="fin-abo-product muted"> · {row.product}</span>
|
||||
</div>
|
||||
<div className={`fin-amount fin-abo-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}>{row.amountText}</div>
|
||||
<div className="fin-abo-meta">
|
||||
<Badge tone={row.tone}>{row.statusLabel}</Badge>
|
||||
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{v.rows.length === 0 ? (
|
||||
<EmptyState title="Noch keine Abos." />
|
||||
) : (
|
||||
<ul className="fin-abos">
|
||||
{v.rows.map((row) => (
|
||||
<li key={row.key} className="fin-abo">
|
||||
<div className="fin-abo-main">
|
||||
<span className="fin-abo-customer">{row.customer}</span>
|
||||
<span className="fin-abo-product muted"> · {row.product}</span>
|
||||
</div>
|
||||
<div className="fin-amount fin-abo-amount">{row.amountText}</div>
|
||||
<div className="fin-abo-meta">
|
||||
<Badge tone={row.tone}>{row.statusLabel}</Badge>
|
||||
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -5,19 +5,20 @@ import { ADDRESS_INCOMPLETE_TEXT, addressComplete } from '../../lib/financeView'
|
||||
* Hinweis „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit Weg zur
|
||||
* Übersicht des Kunden (dort stehen die Stammdaten). Rendert nichts, wenn die Anschrift vollständig ist. Mit
|
||||
* `onShowOverview` (Reiter am Kunden) wechselt ein Knopf zum Reiter Übersicht, sonst führt ein Link zur Kundenseite.
|
||||
* `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden).
|
||||
* `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden). Der Knopf trägt immer
|
||||
* type="button", denn die Variante 'dialog' steht in einem Formular.
|
||||
*/
|
||||
export default function AddressWarning({ customer, onShowOverview, variant = 'page' }) {
|
||||
if (!customer || addressComplete(customer)) return null
|
||||
return (
|
||||
<div className={`fin-address-warn fin-address-warn-${variant}`} role="alert">
|
||||
<div className={`msg warn fin-address-warn fin-address-warn-${variant}`} role="alert">
|
||||
<span>{ADDRESS_INCOMPLETE_TEXT}</span>
|
||||
{onShowOverview ? (
|
||||
<button type="button" className="fin-link" onClick={onShowOverview}>
|
||||
<button type="button" className="link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
) : customer.$id ? (
|
||||
<Link className="fin-link" to={`/customers/${customer.$id}`}>
|
||||
<Link className="link" to={`/customers/${customer.$id}`}>
|
||||
Zur Übersicht
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaXmark } from 'react-icons/fa6'
|
||||
import { Badge, Button } from '../ui'
|
||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||
import { Badge, Button, Dialog, Field } from '../ui'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
||||
import {
|
||||
@@ -20,7 +18,8 @@ const NOTE_MAX = 500
|
||||
* bezahlt markiert und das lässt sich nicht rückgängig machen, deshalb zweistufig: erst „Zuordnen“, dann der
|
||||
* Bestätigungstext mit „Ja, als bezahlt markieren“; vorher geht keine Anfrage raus. Fehler des Servers (409 Betrag
|
||||
* weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
|
||||
* lässt sich mit „Zuordnung lösen“ aufheben.
|
||||
* lässt sich mit „Zuordnung lösen“ aufheben. × und Esc kommen vom Baustein Dialog und wirken nicht, solange eine
|
||||
* Anfrage läuft; lockRef sperrt zusätzlich den Augenblick, bevor der Zustand „busy“ gezeichnet ist.
|
||||
*
|
||||
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand; invoicesLoaded =
|
||||
* frisch und ohne Fehler geladen, dann sind Vorschläge zu nicht mehr offenen Rechnungen gesperrt).
|
||||
@@ -39,14 +38,12 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
const dirtyRef = useRef(false)
|
||||
const dialogRef = useRef(null)
|
||||
const confirmRef = useRef(null)
|
||||
const errorRef = useRef(null)
|
||||
|
||||
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
||||
const unassigned = !tx.category
|
||||
|
||||
const onOverlayKeyDown = useDialogFocus(dialogRef)
|
||||
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
|
||||
useScrollIntoView(errorRef, error)
|
||||
|
||||
@@ -82,15 +79,6 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
||||
onClose({ refresh: dirtyRef.current })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
// Escape schließt zuerst nur die offene Auswahl (z. B. ein Aufklappmenü): dann nicht den Dialog
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) close()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
function choose(key) {
|
||||
setSelected(key)
|
||||
setConfirming(false)
|
||||
@@ -154,7 +142,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
||||
const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
|
||||
|
||||
const radio = (o) => (
|
||||
<label key={o.key} className={`fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
|
||||
<label key={o.key} className={`check fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="fin-assign-target"
|
||||
@@ -166,156 +154,158 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
||||
<span className="fin-choice-text">
|
||||
<span className="fin-choice-label">{o.label}</span>
|
||||
{(o.confidence || o.detail) && (
|
||||
<span className="fin-choice-detail muted">
|
||||
<span className="fin-choice-detail">
|
||||
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
|
||||
{o.confidence && o.detail ? ' · ' : ''}
|
||||
{o.detail}
|
||||
</span>
|
||||
)}
|
||||
{o.disabled && o.disabledReason && <span className="fin-choice-detail fin-hint-warn">{o.disabledReason}</span>}
|
||||
{o.disabled && o.disabledReason && <span className="fin-choice-detail warn">{o.disabledReason}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
|
||||
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
|
||||
<FaXmark aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
className="overlay-content fin-dialog fin-assign"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="fin-assign-title"
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
|
||||
|
||||
<dl className="fin-tx-facts">
|
||||
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
|
||||
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
|
||||
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
|
||||
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
|
||||
</dl>
|
||||
|
||||
{error && (
|
||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.assigned ? (
|
||||
<>
|
||||
<p className="fin-assigned">
|
||||
Zugeordnet: <strong>{model.currentText}</strong>
|
||||
</p>
|
||||
{!model.canUnassign && (
|
||||
<p className="fin-dialog-sub">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
|
||||
)}
|
||||
<div className="fin-dialog-actions">
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Schließen</Button>
|
||||
{model.canUnassign && (
|
||||
<Button type="button" onClick={doUnassign} disabled={disabled}>
|
||||
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{model.hint && <p className="fin-dialog-sub" role="note">{model.hint}</p>}
|
||||
|
||||
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
|
||||
{showInvoiceState && invoicesError && (
|
||||
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
|
||||
)}
|
||||
{outgoing && fixed.loading && <p className="fin-dialog-sub" role="status">Fixkosten werden geladen …</p>}
|
||||
{outgoing && fixed.error && <p className="fin-dialog-warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
|
||||
|
||||
{model.suggestions.length > 0 && (
|
||||
<fieldset className="fin-choices">
|
||||
<legend className="form-label">Vorschläge</legend>
|
||||
{model.suggestions.map(radio)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{model.invoices.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
|
||||
<select
|
||||
id="fin-assign-invoice"
|
||||
className="form-control"
|
||||
value={invoiceSelectValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => choose(e.target.value)}
|
||||
>
|
||||
<option value="">Rechnung wählen …</option>
|
||||
{model.invoices.map((o) => (
|
||||
<option key={o.key} value={o.key} disabled={o.disabled}>
|
||||
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.fixedCosts.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
|
||||
<select
|
||||
id="fin-assign-fixed"
|
||||
className="form-control"
|
||||
value={fixedSelectValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => choose(e.target.value)}
|
||||
>
|
||||
<option value="">Fixkosten wählen …</option>
|
||||
{model.fixedCosts.map((o) => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="fin-choices">
|
||||
<legend className="form-label">Kategorie</legend>
|
||||
{model.categories.map(radio)}
|
||||
</fieldset>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
|
||||
<textarea
|
||||
id="fin-assign-note"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
maxLength={NOTE_MAX}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{confirming && option ? (
|
||||
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
|
||||
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
|
||||
<div className="fin-dialog-actions">
|
||||
<Button type="button" variant="ghost" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
|
||||
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
|
||||
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fin-dialog-actions">
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Abbrechen</Button>
|
||||
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
|
||||
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
let footer
|
||||
if (model.assigned) {
|
||||
footer = (
|
||||
<div className="fin-foot">
|
||||
<Button type="button" onClick={close} disabled={disabled}>Schließen</Button>
|
||||
{model.canUnassign && (
|
||||
<Button type="button" onClick={doUnassign} disabled={disabled}>
|
||||
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (confirming && option) {
|
||||
footer = (
|
||||
<div className="fin-foot">
|
||||
<Button type="button" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
|
||||
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
|
||||
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
footer = (
|
||||
<div className="fin-foot">
|
||||
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
|
||||
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
|
||||
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Buchung zuordnen" onClose={close} busy={disabled} footer={footer}>
|
||||
<dl className="kv">
|
||||
<dt>Datum</dt>
|
||||
<dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd>
|
||||
<dt>Gegenpartei</dt>
|
||||
<dd>{row?.counterparty}</dd>
|
||||
{row?.remittance && (
|
||||
<>
|
||||
<dt>Verwendungszweck</dt>
|
||||
<dd>{row.remittance}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>Betrag</dt>
|
||||
<dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd>
|
||||
</dl>
|
||||
|
||||
{error && (
|
||||
<div className="msg err" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.assigned ? (
|
||||
<>
|
||||
<p className="fin-assigned">
|
||||
Zugeordnet: <strong>{model.currentText}</strong>
|
||||
</p>
|
||||
{!model.canUnassign && (
|
||||
<p className="msg muted">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{model.hint && <p className="msg muted" role="note">{model.hint}</p>}
|
||||
|
||||
{showInvoiceState && invoicesLoading && <p className="msg muted" role="status">Offene Rechnungen werden geladen …</p>}
|
||||
{showInvoiceState && invoicesError && (
|
||||
<p className="msg warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
|
||||
)}
|
||||
{outgoing && fixed.loading && <p className="msg muted" role="status">Fixkosten werden geladen …</p>}
|
||||
{outgoing && fixed.error && <p className="msg warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
|
||||
|
||||
{model.suggestions.length > 0 && (
|
||||
<fieldset className="fin-choices">
|
||||
<legend>Vorschläge</legend>
|
||||
{model.suggestions.map(radio)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{model.invoices.length > 0 && (
|
||||
<Field label="Andere offene Rechnung" htmlFor="fin-assign-invoice">
|
||||
<select
|
||||
id="fin-assign-invoice"
|
||||
className="select"
|
||||
value={invoiceSelectValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => choose(e.target.value)}
|
||||
>
|
||||
<option value="">Rechnung wählen …</option>
|
||||
{model.invoices.map((o) => (
|
||||
<option key={o.key} value={o.key} disabled={o.disabled}>
|
||||
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{model.fixedCosts.length > 0 && (
|
||||
<Field label="Fixkosten" htmlFor="fin-assign-fixed">
|
||||
<select
|
||||
id="fin-assign-fixed"
|
||||
className="select"
|
||||
value={fixedSelectValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => choose(e.target.value)}
|
||||
>
|
||||
<option value="">Fixkosten wählen …</option>
|
||||
{model.fixedCosts.map((o) => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<fieldset className="fin-choices">
|
||||
<legend>Kategorie</legend>
|
||||
{model.categories.map(radio)}
|
||||
</fieldset>
|
||||
|
||||
<Field label="Notiz (optional)" htmlFor="fin-assign-note">
|
||||
<textarea
|
||||
id="fin-assign-note"
|
||||
className="textarea"
|
||||
rows={2}
|
||||
maxLength={NOTE_MAX}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{confirming && option && (
|
||||
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
|
||||
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRotate, FaSpinner } from 'react-icons/fa6'
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import { Badge, Button, Card } from '../ui'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { noticeProps } from '../../lib/financeUi'
|
||||
import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi'
|
||||
import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings'
|
||||
import { safeUrl } from '../../lib/financeView'
|
||||
@@ -67,14 +67,11 @@ export default function BankAccessCard() {
|
||||
return (
|
||||
<Card title="Bankzugang">
|
||||
{res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Bankzugang wird geladen …</p>
|
||||
</div>
|
||||
<div className="loading" role="status">Bankzugang wird geladen …</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
<div className="fin-load-error">
|
||||
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
|
||||
<Button onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -85,36 +82,28 @@ export default function BankAccessCard() {
|
||||
return (
|
||||
<Card title="Bankzugang" actions={<Badge tone={model.tone}>{model.statusLabel}</Badge>}>
|
||||
{res.error && (
|
||||
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">
|
||||
Der Stand konnte nicht neu geladen werden: {res.error}
|
||||
</p>
|
||||
<p {...noticeProps('warn', 'fin-notice')}>Der Stand konnte nicht neu geladen werden: {res.error}</p>
|
||||
)}
|
||||
{model.rows.length > 0 && (
|
||||
<dl className="fin-bank-facts">
|
||||
<dl className="kv fin-kv">
|
||||
{model.rows.map((row) => (
|
||||
<div key={row.key}>
|
||||
<Fragment key={row.key}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.text}</dd>
|
||||
</div>
|
||||
<dd className="num">{row.text}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{model.error && (
|
||||
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">Fehler: {model.error}</p>
|
||||
)}
|
||||
{model.hint && <p className="fin-hint fin-form-notice">{model.hint}</p>}
|
||||
{message && (
|
||||
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{model.error && <p {...noticeProps('warn', 'fin-notice')}>Fehler: {model.error}</p>}
|
||||
{model.hint && <p {...noticeProps('info', 'fin-notice')}>{model.hint}</p>}
|
||||
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
|
||||
<div className="fin-form-actions">
|
||||
{model.canRefresh && (
|
||||
<Button variant="primary" onClick={refresh} disabled={busy !== null}>
|
||||
<FaRotate className={busy === 'refresh' ? 'fin-spin' : undefined} aria-hidden="true" /> {busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
|
||||
{busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={model.canRefresh ? 'ghost' : 'primary'} onClick={connect} disabled={busy !== null}>
|
||||
<Button variant={model.canRefresh ? 'default' : 'primary'} onClick={connect} disabled={busy !== null}>
|
||||
{busy === 'connect' ? 'Leite weiter …' : model.connectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, EmptyState } from '../ui'
|
||||
import { Button, EmptyState } from '../ui'
|
||||
import AddressWarning from './AddressWarning'
|
||||
import InvoiceTable from './InvoiceTable'
|
||||
import { customerInvoicesGate } from '../../lib/financeView'
|
||||
@@ -13,13 +13,11 @@ export default function CustomerInvoices({ customer, onShowOverview }) {
|
||||
const gate = customerInvoicesGate(customer)
|
||||
if (!gate.ok) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title={gate.title}
|
||||
hint={gate.hint}
|
||||
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
||||
/>
|
||||
</Card>
|
||||
<EmptyState
|
||||
title={gate.title}
|
||||
hint={gate.hint}
|
||||
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaSpinner } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import FormField from './FormField'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { noticeProps } from '../../lib/financeUi'
|
||||
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
|
||||
import {
|
||||
FIXED_COST_DELETE_CONFIRM,
|
||||
@@ -146,45 +146,44 @@ export default function FixedCostsCard() {
|
||||
let body
|
||||
if (!res.data) {
|
||||
body = res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Fixkosten werden geladen …</p>
|
||||
</div>
|
||||
<div className="loading" role="status">Fixkosten werden geladen …</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
<div className="fin-load-error">
|
||||
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
|
||||
<Button onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
{res.error && (
|
||||
<p className="fin-hint fin-hint-warn" role="alert">
|
||||
<p {...noticeProps('warn', 'fin-notice')}>
|
||||
Die Liste konnte nicht neu geladen werden: {res.error}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={res.reload} disabled={res.loading}>Erneut versuchen</Button>
|
||||
<button type="button" className="link" onClick={res.reload} disabled={res.loading}>Erneut versuchen</button>
|
||||
</p>
|
||||
)}
|
||||
{editing && (
|
||||
<form className="fin-fixed-form" onSubmit={submit} noValidate aria-labelledby="fin-fix-form-title">
|
||||
<h4 id="fin-fix-form-title" className="fin-fixed-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h4>
|
||||
<h3 id="fin-fix-form-title" className="fin-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h3>
|
||||
<fieldset className="fin-fieldset" disabled={busy === 'save'}>
|
||||
<div className="fin-form-grid">
|
||||
<div className="field-row">
|
||||
<FormField id={fieldId('name')} label="Name" error={errors.name}>
|
||||
<input className="form-control" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
|
||||
<input className="input" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
|
||||
</FormField>
|
||||
<FormField id={fieldId('amount')} label="Betrag (€)" error={errors.amount}>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
|
||||
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
|
||||
<select className="form-control" value={form.interval} onChange={change('interval')}>
|
||||
<select className="select" value={form.interval} onChange={change('interval')}>
|
||||
{INTERVAL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField id={fieldId('nextDate')} label="Nächster Termin" error={errors.nextDate}>
|
||||
<input className="form-control" type="date" value={form.nextDate} onChange={change('nextDate')} />
|
||||
<input className="input" type="date" value={form.nextDate} onChange={change('nextDate')} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
@@ -193,22 +192,18 @@ export default function FixedCostsCard() {
|
||||
hint={`Teil des Namens der Gegenpartei in der Bankbuchung, z. B. hetzner (höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen). Damit schlägt die Seite diese Abbuchung nicht noch einmal als neue Fixkosten vor.`}
|
||||
error={errors.counterpartyMatch}
|
||||
>
|
||||
<input className="form-control" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
|
||||
<input className="input" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
|
||||
</FormField>
|
||||
</fieldset>
|
||||
<div className="fin-form-actions">
|
||||
<Button type="submit" variant="primary" disabled={busy !== null}>
|
||||
{busy === 'save' ? 'Speichere …' : 'Speichern'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
|
||||
<Button type="button" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{message && (
|
||||
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title="Noch keine Fixkosten." hint="Lege regelmäßige Ausgaben an, damit Vorschau und „Verfügbar“ sie berücksichtigen." />
|
||||
) : (
|
||||
@@ -222,13 +217,13 @@ export default function FixedCostsCard() {
|
||||
<div className="fin-amount fin-fixed-amount">{row.amountText}</div>
|
||||
<div className="fin-fixed-meta muted">{row.metaText}</div>
|
||||
<div className="fin-actions fin-fixed-actions">
|
||||
<Button size="sm" variant="ghost" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
|
||||
<button type="button" className="link" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
|
||||
</button>
|
||||
<button type="button" className="link" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
|
||||
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
|
||||
</button>
|
||||
<Button type="button" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
|
||||
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -245,8 +240,8 @@ export default function FixedCostsCard() {
|
||||
title="Fixkosten"
|
||||
actions={
|
||||
res.data && (
|
||||
<Button size="sm" variant="primary" onClick={openNew} disabled={busy !== null || editing?.id === null}>
|
||||
<FaPlus aria-hidden="true" /> Neue Position
|
||||
<Button onClick={openNew} disabled={busy !== null || editing?.id === null}>
|
||||
Neue Position
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FaTriangleExclamation } from 'react-icons/fa6'
|
||||
import { Badge, Card, EmptyState } from '../ui'
|
||||
import { forecastNotes, forecastRows } from '../../lib/financeView'
|
||||
import { isNegText } from '../../lib/financeUi'
|
||||
|
||||
/**
|
||||
* Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen
|
||||
* und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis).
|
||||
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte bis 560 px)
|
||||
* wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
|
||||
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Wrapper .fin-body bis
|
||||
* 560 px) wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
|
||||
*/
|
||||
export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
|
||||
const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
|
||||
@@ -15,70 +15,77 @@ export default function ForecastTable({ vorschau, kontostandCents, stripeDown =
|
||||
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
|
||||
|
||||
return (
|
||||
<Card title="Vorschau 30 Tage" className="fin-forecast-card" bodyStyle={{ padding: 0 }}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
|
||||
) : (
|
||||
<div className="fin-table-wrap">
|
||||
<table className={`ui-table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
|
||||
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col">Posten</th>
|
||||
<th role="columnheader" scope="col">Sicherheit</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||
{hasBalance && (
|
||||
<>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key} role="row">
|
||||
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
|
||||
<td role="cell" className="fin-forecast-label">{row.label}</td>
|
||||
<td role="cell" className="fin-forecast-kind">
|
||||
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
|
||||
</td>
|
||||
<td role="cell" className="fin-amount fin-forecast-amount">{row.amountText}</td>
|
||||
<Card title="Vorschau 30 Tage">
|
||||
<div className="fin-body">
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
|
||||
) : (
|
||||
<div className="fin-table-wrap">
|
||||
<table className={`table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
|
||||
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col">Posten</th>
|
||||
<th role="columnheader" scope="col">Sicherheit</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||
{hasBalance && (
|
||||
<>
|
||||
<td
|
||||
role="cell"
|
||||
data-label="Saldo sicher"
|
||||
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
|
||||
>
|
||||
<span className="fin-saldo">{row.saldoSicherText}</span>
|
||||
</td>
|
||||
<td
|
||||
role="cell"
|
||||
data-label="Saldo erwartet"
|
||||
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
|
||||
>
|
||||
<span className="fin-saldo">{row.saldoErwartetText}</span>
|
||||
</td>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(notes.length > 0 || assumption) && (
|
||||
<div className="fin-forecast-foot">
|
||||
{notes.map((note) => (
|
||||
<p key={note.key} className="fin-hint fin-hint-warn" role="status">
|
||||
<FaTriangleExclamation aria-hidden="true" /> {note.text}
|
||||
</p>
|
||||
))}
|
||||
{assumption && <p className="fin-hint">{assumption}</p>}
|
||||
</div>
|
||||
)}
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key} role="row">
|
||||
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
|
||||
<td role="cell" className="fin-forecast-label">{row.label}</td>
|
||||
<td role="cell" className="fin-forecast-kind">
|
||||
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
|
||||
</td>
|
||||
<td
|
||||
role="cell"
|
||||
className={`fin-amount fin-forecast-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}
|
||||
>
|
||||
{row.amountText}
|
||||
</td>
|
||||
{hasBalance && (
|
||||
<>
|
||||
<td
|
||||
role="cell"
|
||||
data-label="Saldo sicher"
|
||||
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
|
||||
>
|
||||
<span className="fin-saldo">{row.saldoSicherText}</span>
|
||||
</td>
|
||||
<td
|
||||
role="cell"
|
||||
data-label="Saldo erwartet"
|
||||
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
|
||||
>
|
||||
<span className="fin-saldo">{row.saldoErwartetText}</span>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(notes.length > 0 || assumption) && (
|
||||
<div className="fin-forecast-foot">
|
||||
{notes.map((note) => (
|
||||
<p key={note.key} className="msg warn" role="status">
|
||||
{note.text}
|
||||
</p>
|
||||
))}
|
||||
{assumption && <p className="msg muted">{assumption}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { cloneElement } from 'react'
|
||||
|
||||
/**
|
||||
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. `children` ist genau ein Eingabeelement; es bekommt
|
||||
* `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler). Die Fehlermeldung wird vorgelesen (role="alert").
|
||||
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung (Klassen des Bausteins `Field`: `.field`, `.hint`, `.msg.err`).
|
||||
* `children` ist genau ein Eingabeelement; es bekommt `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler).
|
||||
* Den roten Ring am Feld liefert allein `aria-invalid`. Die Fehlermeldung wird vorgelesen (role="alert").
|
||||
*/
|
||||
export default function FormField({ id, label, hint, error, children }) {
|
||||
const hintId = hint ? `${id}-hint` : undefined
|
||||
@@ -13,12 +14,12 @@ export default function FormField({ id, label, hint, error, children }) {
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })}
|
||||
{hint && (
|
||||
<div id={hintId} className="faint fin-form-hint">
|
||||
<div id={hintId} className="hint">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div id={errorId} className="fin-field-error" role="alert">
|
||||
<div id={errorId} className="msg err" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
|
||||
import { AutocompleteInput, Button } from '../ui'
|
||||
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
|
||||
import AddressWarning from './AddressWarning'
|
||||
import { useCustomers } from '../../hooks/useCustomers'
|
||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import { createInvoice } from '../../lib/financeApi'
|
||||
import { canInvoice, euro } from '../../lib/financeFormat'
|
||||
@@ -65,10 +63,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
||||
const [busy, setBusy] = useState(null) // 'draft' | 'send'
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
const dialogRef = useRef(null)
|
||||
const errorRef = useRef(null)
|
||||
|
||||
const onOverlayKeyDown = useDialogFocus(dialogRef)
|
||||
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
|
||||
useScrollIntoView(errorRef, error)
|
||||
|
||||
@@ -79,14 +75,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
|
||||
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
// × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
|
||||
// Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
|
||||
const close = () => {
|
||||
if (lockRef.current) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
|
||||
const resolved = fixedCustomer
|
||||
@@ -144,203 +138,183 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
||||
|
||||
const disabled = busy !== null
|
||||
|
||||
return (
|
||||
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
|
||||
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
|
||||
<FaXmark aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
className="overlay-content fin-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="fin-invoice-title"
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
const footer = (
|
||||
<div className="fin-foot">
|
||||
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
|
||||
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
||||
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => submit(true)}
|
||||
disabled={disabled || !gate.sendAllowed}
|
||||
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||
>
|
||||
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
||||
|
||||
{error && (
|
||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
|
||||
{fixedCustomer ? (
|
||||
<p className="fin-fixed-customer">
|
||||
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
|
||||
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
|
||||
</p>
|
||||
) : source?.loading ? (
|
||||
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
|
||||
) : source?.error ? (
|
||||
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
||||
) : (
|
||||
<AutocompleteInput
|
||||
id="fin-invoice-customer"
|
||||
value={customerText}
|
||||
onChange={(v) => {
|
||||
setCustomerText(v)
|
||||
if (error) setError('')
|
||||
}}
|
||||
getSuggestions={(t) => customerSuggestions(options, t)}
|
||||
placeholder="Kunde suchen …"
|
||||
/>
|
||||
)}
|
||||
{!customerBlocked.ok && (
|
||||
<p className="fin-dialog-warn" role="alert">
|
||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||
</p>
|
||||
)}
|
||||
{!fixedCustomer && resolved.state === 'unknown' && (
|
||||
<p className="fin-dialog-sub">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
|
||||
)}
|
||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
|
||||
)}
|
||||
{gate.warn && (
|
||||
<AddressWarning
|
||||
customer={customer}
|
||||
variant="dialog"
|
||||
onShowOverview={
|
||||
onShowOverview
|
||||
? () => {
|
||||
if (lockRef.current) return
|
||||
onClose()
|
||||
onShowOverview()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset className="fin-items">
|
||||
<legend className="form-label">Positionen</legend>
|
||||
{items.map((item, index) => {
|
||||
const line = lineTotalCents(item)
|
||||
const n = index + 1
|
||||
return (
|
||||
<div key={item.uid} className="fin-item">
|
||||
<div className="fin-item-field fin-item-desc">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
||||
<input
|
||||
id={`fin-item-desc-${item.uid}`}
|
||||
className="form-control"
|
||||
maxLength={INVOICE_LIMITS.maxDescription}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Webdesign Startseite"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-qty">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
||||
<input
|
||||
id={`fin-item-qty-${item.uid}`}
|
||||
className="form-control fin-amount"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-price">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
||||
<input
|
||||
id={`fin-item-price-${item.uid}`}
|
||||
className="form-control fin-amount"
|
||||
inputMode="decimal"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-sum">
|
||||
<span className="fin-item-label">Summe</span>
|
||||
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="fin-icon-btn"
|
||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
||||
disabled={items.length <= 1}
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
title="Position entfernen"
|
||||
>
|
||||
<FaTrashCan aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="fin-items-foot">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
||||
>
|
||||
<FaPlus aria-hidden="true" /> Position
|
||||
</Button>
|
||||
<div className="fin-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span className="fin-amount fin-total-value">{euro(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="fin-dialog-row">
|
||||
<div className="form-group fin-days">
|
||||
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
|
||||
<input
|
||||
id="fin-invoice-days"
|
||||
className="form-control fin-amount"
|
||||
inputMode="numeric"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group fin-memo">
|
||||
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
|
||||
<textarea
|
||||
id="fin-invoice-memo"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
maxLength={INVOICE_LIMITS.maxMemo}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fin-dialog-actions">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={disabled}>Abbrechen</Button>
|
||||
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
||||
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => submit(true)}
|
||||
disabled={disabled || !gate.sendAllowed}
|
||||
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||
>
|
||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
|
||||
return (
|
||||
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={disabled} onSubmit={(e) => e.preventDefault()} footer={footer}>
|
||||
{error && (
|
||||
<div className="msg err" role="alert" ref={errorRef}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Kunde" htmlFor="fin-invoice-customer">
|
||||
{fixedCustomer ? (
|
||||
<p className="fin-fixed-customer">
|
||||
<strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
|
||||
{fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
|
||||
</p>
|
||||
) : source?.loading ? (
|
||||
<p className="msg muted" role="status">Kunden werden geladen …</p>
|
||||
) : source?.error ? (
|
||||
<p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
||||
) : (
|
||||
<AutocompleteInput
|
||||
id="fin-invoice-customer"
|
||||
value={customerText}
|
||||
onChange={(v) => {
|
||||
setCustomerText(v)
|
||||
if (error) setError('')
|
||||
}}
|
||||
getSuggestions={(t) => customerSuggestions(options, t)}
|
||||
placeholder="Kunde suchen …"
|
||||
/>
|
||||
)}
|
||||
{!customerBlocked.ok && (
|
||||
<p className="msg warn" role="alert">
|
||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||
</p>
|
||||
)}
|
||||
{!fixedCustomer && resolved.state === 'unknown' && (
|
||||
<p className="msg muted">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
|
||||
)}
|
||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||
<p className="msg muted">Die Rechnung geht an {customer.email}.</p>
|
||||
)}
|
||||
{gate.warn && (
|
||||
<AddressWarning
|
||||
customer={customer}
|
||||
variant="dialog"
|
||||
onShowOverview={
|
||||
onShowOverview
|
||||
? () => {
|
||||
if (lockRef.current) return
|
||||
onClose()
|
||||
onShowOverview()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<fieldset className="fin-items">
|
||||
<legend>Positionen</legend>
|
||||
{items.map((item, index) => {
|
||||
const line = lineTotalCents(item)
|
||||
const n = index + 1
|
||||
return (
|
||||
<div key={item.uid} className="fin-item">
|
||||
<div className="fin-item-field fin-item-desc">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
||||
<input
|
||||
id={`fin-item-desc-${item.uid}`}
|
||||
className="input"
|
||||
maxLength={INVOICE_LIMITS.maxDescription}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Webdesign Startseite"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-qty">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
||||
<input
|
||||
id={`fin-item-qty-${item.uid}`}
|
||||
className="input fin-amount"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-price">
|
||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
||||
<input
|
||||
id={`fin-item-price-${item.uid}`}
|
||||
className="input fin-amount"
|
||||
inputMode="decimal"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-sum">
|
||||
<span className="fin-item-label">Summe</span>
|
||||
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
|
||||
</div>
|
||||
<div className="fin-item-field fin-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
||||
disabled={items.length <= 1}
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="fin-items-foot">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
||||
>
|
||||
+ Position
|
||||
</Button>
|
||||
<div className="fin-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span className="fin-amount">{euro(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="fin-dialog-row">
|
||||
<Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
|
||||
<input
|
||||
id="fin-invoice-days"
|
||||
className="input fin-amount"
|
||||
inputMode="numeric"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
|
||||
<textarea
|
||||
id="fin-invoice-memo"
|
||||
className="textarea"
|
||||
rows={2}
|
||||
maxLength={INVOICE_LIMITS.maxMemo}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaSpinner } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
|
||||
import InvoiceCreateModal from './InvoiceCreateModal'
|
||||
import Pager from './Pager'
|
||||
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
||||
import { pageOf } from '../../lib/financeFormat'
|
||||
import { noticeProps } from '../../lib/financeUi'
|
||||
import {
|
||||
ADDRESS_INCOMPLETE_TEXT,
|
||||
DEFAULT_INVOICE_FILTER,
|
||||
@@ -33,8 +33,8 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
|
||||
* eine Anfrage läuft, sind alle Aktionen gesperrt. „Rechnung schreiben“ öffnet den Dialog.
|
||||
*
|
||||
* customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
|
||||
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede
|
||||
* Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
|
||||
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Wrapper .fin-body bis 860 px)
|
||||
* wird jede Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
|
||||
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
|
||||
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
|
||||
*
|
||||
@@ -147,137 +147,147 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
||||
changed()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
|
||||
<Card
|
||||
title="Rechnungen"
|
||||
className="fin-invoices-card"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
actions={
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<FaPlus aria-hidden="true" /> Rechnung schreiben
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
Rechnung schreiben
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ages.length > 0 && (
|
||||
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
|
||||
{ages.map((cell) => (
|
||||
<li key={cell.key} className="fin-age">
|
||||
<span className="fin-age-label">{cell.label}</span>
|
||||
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
|
||||
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="fin-body">
|
||||
{ages.length > 0 && (
|
||||
<ul className="stats fin-ages" aria-label="Offene Rechnungen nach Alter">
|
||||
{ages.map((cell) => (
|
||||
<li key={cell.key}>
|
||||
<Stat label={cell.label} value={cell.sumText} sub={`${cell.count} ${cell.count === 1 ? 'Rechnung' : 'Rechnungen'}`} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="fin-toolbar">
|
||||
<div className="seg" role="group" aria-label="Rechnungen filtern">
|
||||
{INVOICE_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={status === f.key ? 'active' : ''}
|
||||
aria-pressed={status === f.key}
|
||||
onClick={() => pickFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="toolbar">
|
||||
<div className="seg" role="group" aria-label="Rechnungen filtern">
|
||||
{INVOICE_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={status === f.key ? 'on' : ''}
|
||||
aria-pressed={status === f.key}
|
||||
onClick={() => pickFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Rechnungen werden geladen …</p>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="fin-load-error">
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
|
||||
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
|
||||
) : (
|
||||
<div className="fin-table-wrap">
|
||||
<table className="ui-table fin-invoices" role="table">
|
||||
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Nr.</th>
|
||||
<th role="columnheader" scope="col">Kunde</th>
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col">Fällig</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
|
||||
<th role="columnheader" scope="col">Status</th>
|
||||
<th role="columnheader" scope="col">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{paged.items.map((row) => (
|
||||
<tr key={row.key} role="row">
|
||||
<td role="cell" className="fin-inv-number">
|
||||
{row.numberText}
|
||||
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
|
||||
</td>
|
||||
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
|
||||
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
|
||||
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
|
||||
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
|
||||
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
|
||||
<td role="cell" className="fin-inv-status">
|
||||
<div className="fin-inv-statusbox">
|
||||
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
|
||||
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell" className="fin-inv-actions">
|
||||
<div className="fin-actions">
|
||||
{row.actions.map((action) =>
|
||||
action.url ? (
|
||||
<Button
|
||||
key={action.key}
|
||||
as="a"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
href={action.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={action.key}
|
||||
size="sm"
|
||||
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
|
||||
onClick={() => run(row, action)}
|
||||
disabled={busy !== null || (action.key === 'send' && sendBlocked(byId[row.id]))}
|
||||
title={action.key === 'send' && sendBlocked(byId[row.id]) ? ADDRESS_INCOMPLETE_TEXT : undefined}
|
||||
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
|
||||
>
|
||||
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{loading ? (
|
||||
<div className="loading" role="status">Rechnungen werden geladen …</div>
|
||||
) : loadError ? (
|
||||
<div className="fin-load-error">
|
||||
<p className="msg warn" role="alert">{loadError}</p>
|
||||
<Button onClick={reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
|
||||
) : (
|
||||
<div className="fin-table-wrap">
|
||||
<table className="table fin-invoices" role="table">
|
||||
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Nr.</th>
|
||||
<th role="columnheader" scope="col">Kunde</th>
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col">Fällig</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
|
||||
<th role="columnheader" scope="col">Status</th>
|
||||
<th role="columnheader" scope="col">Aktionen</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{paged.items.map((row) => (
|
||||
<tr key={row.key} role="row">
|
||||
<td role="cell" className="fin-inv-number">
|
||||
{row.numberText}
|
||||
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
|
||||
</td>
|
||||
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
|
||||
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
|
||||
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
|
||||
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
|
||||
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
|
||||
<td role="cell" className="fin-inv-status">
|
||||
<div className="fin-inv-statusbox">
|
||||
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
|
||||
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell" className="fin-inv-actions">
|
||||
<div className="fin-actions">
|
||||
{row.actions.map((action) => {
|
||||
const label = `${action.label}: ${row.numberText}, ${row.customerText}`
|
||||
if (action.url) {
|
||||
return (
|
||||
<a
|
||||
key={action.key}
|
||||
className="link"
|
||||
href={action.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
const running = busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label
|
||||
if (action.key === 'delete' || action.key === 'void') {
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => run(row, action)}
|
||||
disabled={busy !== null}
|
||||
aria-label={label}
|
||||
>
|
||||
{running}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
const gated = action.key === 'send' && sendBlocked(byId[row.id])
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => run(row, action)}
|
||||
disabled={busy !== null || gated}
|
||||
title={gated ? ADDRESS_INCOMPLETE_TEXT : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
{running}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<InvoiceCreateModal
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { FaChevronDown, FaChevronUp } from 'react-icons/fa6'
|
||||
import { Kpi } from '../ui'
|
||||
import { Stat } from '../ui'
|
||||
import { euroWhole } from '../../lib/financeFormat'
|
||||
import { breakdownRows, lowPointLines } from '../../lib/financeView'
|
||||
import { isNegText } from '../../lib/financeUi'
|
||||
|
||||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||
|
||||
@@ -15,20 +15,21 @@ export default function LiquidityHead({ kopf }) {
|
||||
const hasBalance = isNum(k.kontostandCents)
|
||||
const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
|
||||
const lows = lowPointLines(k)
|
||||
const anyLowNegative = lows.some((l) => l && l.negative)
|
||||
const balanceText = euroWhole(k.kontostandCents)
|
||||
|
||||
return (
|
||||
<div className="kpi-grid fin-head">
|
||||
<Kpi
|
||||
<div className="stats fin-head">
|
||||
<Stat
|
||||
label="Kontostand"
|
||||
value={euroWhole(k.kontostandCents)}
|
||||
value={balanceText}
|
||||
tone={isNegText(balanceText) ? 'neg' : ''}
|
||||
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
|
||||
/>
|
||||
|
||||
<Kpi
|
||||
<Stat
|
||||
label="Verfügbar nach Rücklagen"
|
||||
value={euroWhole(k.verfuegbarCents)}
|
||||
accent={shortfall ? 'var(--danger)' : 'var(--accent)'}
|
||||
tone={shortfall ? 'neg' : ''}
|
||||
sub={
|
||||
<>
|
||||
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
|
||||
@@ -36,12 +37,12 @@ export default function LiquidityHead({ kopf }) {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fin-link"
|
||||
className="link"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
Aufschlüsselung {open ? <FaChevronUp aria-hidden="true" /> : <FaChevronDown aria-hidden="true" />}
|
||||
Aufschlüsselung <span aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul id={listId} className="fin-breakdown">
|
||||
@@ -60,9 +61,8 @@ export default function LiquidityHead({ kopf }) {
|
||||
}
|
||||
/>
|
||||
|
||||
<Kpi
|
||||
<Stat
|
||||
label="Tiefster Stand in 30 Tagen"
|
||||
accent={anyLowNegative ? 'var(--danger)' : 'var(--info)'}
|
||||
value={
|
||||
lows.some(Boolean) ? (
|
||||
<div className="fin-lows">
|
||||
@@ -71,7 +71,7 @@ export default function LiquidityHead({ kopf }) {
|
||||
low && (
|
||||
<div key={low.key} className="fin-low">
|
||||
<span className="fin-low-label">{low.label}:</span>
|
||||
<span className="fin-amount">{low.amountText}</span>
|
||||
<span className={`fin-amount${low.negative ? ' fin-neg' : ''}`}>{low.amountText}</span>
|
||||
<span className="fin-low-when">
|
||||
am {low.dateText}
|
||||
{low.negative && <span className="fin-flag"> · im Minus</span>}
|
||||
|
||||
@@ -7,15 +7,15 @@ import { Button } from '../ui'
|
||||
export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
|
||||
if (pages <= 1) return null
|
||||
return (
|
||||
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
|
||||
<nav className="table-foot fin-pager" aria-label={`Seiten der ${noun}`}>
|
||||
<Button type="button" variant="quiet" onClick={() => onPage(page - 1)} disabled={page <= 1}>
|
||||
Zurück
|
||||
</Button>
|
||||
<span className="fin-pager-info" aria-live="polite">
|
||||
Seite {page} von {pages}
|
||||
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onPage(page + 1)} disabled={page >= pages}>
|
||||
<Button type="button" variant="quiet" onClick={() => onPage(page + 1)} disabled={page >= pages}>
|
||||
Weiter
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaFloppyDisk, FaSpinner } from 'react-icons/fa6'
|
||||
import { Button, Card } from '../ui'
|
||||
import FormField from './FormField'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { noticeProps } from '../../lib/financeUi'
|
||||
import { getSettings, patchSettings } from '../../lib/financeApi'
|
||||
import {
|
||||
BANK_DETAILS_EXAMPLE,
|
||||
@@ -29,14 +29,11 @@ export default function SettingsCard() {
|
||||
return (
|
||||
<Card title="Einstellungen">
|
||||
{res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Einstellungen werden geladen …</p>
|
||||
</div>
|
||||
<div className="loading" role="status">Einstellungen werden geladen …</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
<div className="fin-load-error">
|
||||
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
|
||||
<Button onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -103,14 +100,14 @@ function SettingsForm({ initial }) {
|
||||
<form onSubmit={submit} noValidate>
|
||||
{/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */}
|
||||
<fieldset className="fin-fieldset" disabled={busy}>
|
||||
<div className="fin-form-grid">
|
||||
<div className="field-row">
|
||||
<FormField
|
||||
id={fieldId('minBuffer')}
|
||||
label="Mindestpuffer (€)"
|
||||
hint="Dieser Betrag bleibt bei „Verfügbar“ immer auf dem Konto. 0 ist möglich."
|
||||
error={errors.minBuffer}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
|
||||
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
|
||||
</FormField>
|
||||
<FormField
|
||||
id={fieldId('taxReservePercent')}
|
||||
@@ -118,15 +115,17 @@ function SettingsForm({ initial }) {
|
||||
hint={`Anteil des Gewinns, der als Rücklage gilt (0 bis ${SETTINGS_LIMITS.maxPercent}).`}
|
||||
error={errors.taxReservePercent}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
|
||||
<input className="input fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<FormField
|
||||
id={fieldId('previousYearRevenue')}
|
||||
label="Vorjahresumsatz (€)"
|
||||
hint="Für die Kleinunternehmer-Grenze. Leer lassen, wenn unbekannt."
|
||||
error={errors.previousYearRevenue}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
|
||||
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
|
||||
</FormField>
|
||||
<FormField
|
||||
id={fieldId('estPrepayment')}
|
||||
@@ -134,7 +133,7 @@ function SettingsForm({ initial }) {
|
||||
hint="Betrag je Termin (10.03., 10.06., 10.09., 10.12.). 0 = keine Vorauszahlung."
|
||||
error={errors.estPrepayment}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
|
||||
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -144,21 +143,17 @@ function SettingsForm({ initial }) {
|
||||
hint={`Steht im Fuß jeder Rechnung, z. B. „${BANK_DETAILS_EXAMPLE}“.`}
|
||||
error={errors.bankDetailsText}
|
||||
>
|
||||
<textarea className="form-control" rows={3} value={form.bankDetailsText} onChange={change('bankDetailsText')} />
|
||||
<textarea className="textarea" rows={3} value={form.bankDetailsText} onChange={change('bankDetailsText')} />
|
||||
</FormField>
|
||||
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
|
||||
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{notice && (
|
||||
<p className={`fin-hint fin-form-notice${notice.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={notice.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
|
||||
<div className="fin-form-actions">
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" aria-hidden="true" /> : <FaFloppyDisk aria-hidden="true" />} {busy ? 'Speichere …' : 'Speichern'}
|
||||
{busy ? 'Speichere …' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRotate, FaTriangleExclamation } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { statusItems } from '../../lib/financeView'
|
||||
import { noticeProps } from '../../lib/financeUi'
|
||||
|
||||
/**
|
||||
* Block 0: Stand von Bank und Stripe in einer Zeile und der Knopf „Aktualisieren“.
|
||||
@@ -37,21 +37,15 @@ export default function StatusLine({ stand, onRefresh, refreshing = false }) {
|
||||
<div className="fin-status-items">
|
||||
{items.map((item) => (
|
||||
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
|
||||
{!item.ok && <FaTriangleExclamation aria-hidden="true" />}
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={handleRefresh} disabled={refreshing}>
|
||||
<FaRotate className={refreshing ? 'fin-spin' : undefined} aria-hidden="true" />{' '}
|
||||
<Button onClick={handleRefresh} disabled={refreshing}>
|
||||
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
|
||||
</Button>
|
||||
</div>
|
||||
{hint && (
|
||||
<p className={`fin-hint${hint.limited ? '' : ' fin-hint-warn'}`} role={hint.limited ? 'status' : 'alert'}>
|
||||
{hint.text}
|
||||
</p>
|
||||
)}
|
||||
{hint && <p {...noticeProps(hint.limited ? 'info' : 'warn', 'fin-notice')}>{hint.text}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card } from '../ui'
|
||||
import { taxView } from '../../lib/financeView'
|
||||
import { isNegText } from '../../lib/financeUi'
|
||||
|
||||
/**
|
||||
* Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
|
||||
@@ -10,43 +11,49 @@ import { taxView } from '../../lib/financeView'
|
||||
export default function TaxCard({ steuer }) {
|
||||
const v = taxView(steuer)
|
||||
return (
|
||||
<Card title="Steuer" className="fin-tax-card">
|
||||
<div className="fin-facts">
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-row">
|
||||
<span className="fin-fact-label">Steuer-Rücklage</span>
|
||||
<span className="fin-amount fin-fact-big">{v.reserve.amountText}</span>
|
||||
</div>
|
||||
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
|
||||
</div>
|
||||
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
|
||||
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
|
||||
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
|
||||
<div className="fin-fact-sub">{v.small.limitText}</div>
|
||||
<div className="fin-fact-sub">{v.small.hardText}</div>
|
||||
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
|
||||
</div>
|
||||
|
||||
{v.gewerbeText && (
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-label">Gewerbesteuer</div>
|
||||
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{v.prepayment && (
|
||||
<Card title="Steuer">
|
||||
<div className="fin-body">
|
||||
<div className="fin-facts">
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-row">
|
||||
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
|
||||
<span className="fin-amount fin-fact-strong">{v.prepayment.amountText}</span>
|
||||
<span className="fin-fact-label">Steuer-Rücklage</span>
|
||||
<span className={`fin-amount fin-fact-big${isNegText(v.reserve.amountText) ? ' fin-neg' : ''}`}>
|
||||
{v.reserve.amountText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
|
||||
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="fin-hint fin-tax-note">{v.noteText}</p>
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
|
||||
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
|
||||
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
|
||||
<div className="fin-fact-sub">{v.small.limitText}</div>
|
||||
<div className="fin-fact-sub">{v.small.hardText}</div>
|
||||
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
|
||||
</div>
|
||||
|
||||
{v.gewerbeText && (
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-label">Gewerbesteuer</div>
|
||||
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{v.prepayment && (
|
||||
<div className="fin-fact">
|
||||
<div className="fin-fact-row">
|
||||
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
|
||||
<span className={`fin-amount fin-fact-strong${isNegText(v.prepayment.amountText) ? ' fin-neg' : ''}`}>
|
||||
{v.prepayment.amountText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="msg muted fin-tax-note">{v.noteText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import { Badge, Card, EmptyState } from '../ui'
|
||||
import { TODO_LIMIT, todoButtonLabel, todoKey, urgencyOf, visibleTodos } from '../../lib/financeView'
|
||||
|
||||
/**
|
||||
@@ -35,12 +35,7 @@ export default function TodoList({ aufgaben, onAction }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Zu erledigen"
|
||||
actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}
|
||||
className="fin-todo-card"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Card title="Zu erledigen" actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}>
|
||||
{total === 0 ? (
|
||||
<EmptyState title="Nichts offen" />
|
||||
) : (
|
||||
@@ -51,31 +46,31 @@ export default function TodoList({ aufgaben, onAction }) {
|
||||
const urgency = urgencyOf(todo)
|
||||
const label = todoButtonLabel(todo)
|
||||
return (
|
||||
<li key={key} className={`fin-todo fin-todo-${todo.dringlichkeit || 'info'}`}>
|
||||
<li key={key} className="fin-todo">
|
||||
<Badge tone={urgency.tone}>{urgency.label}</Badge>
|
||||
<div className="fin-todo-main">
|
||||
<div>
|
||||
<div className="fin-todo-title">{todo.titel}</div>
|
||||
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
|
||||
{failure?.key === key && (
|
||||
<div className="fin-hint fin-hint-warn" role="alert">
|
||||
<div className="msg warn" role="alert">
|
||||
{failure.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{label && (
|
||||
<Button size="sm" onClick={() => run(todo, key)} disabled={running !== null}>
|
||||
<button type="button" className="link" onClick={() => run(todo, key)} disabled={running !== null}>
|
||||
{running === key ? 'Moment …' : label}
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{total > TODO_LIMIT && (
|
||||
<div className="fin-todos-more">
|
||||
<Button size="sm" variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||
<div className="table-foot">
|
||||
<button type="button" className="link" onClick={() => setExpanded((v) => !v)}>
|
||||
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import Pager from './Pager'
|
||||
import { pageOf } from '../../lib/financeFormat'
|
||||
import { isNegText, noticeProps } from '../../lib/financeUi'
|
||||
import { transactionRows } from '../../lib/financeView'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -13,8 +13,8 @@ export const TRANSACTIONS_ANCHOR = 'finanzen-buchungen'
|
||||
* Verwendungszweck (zweizeilig), Betrag mit Vorzeichen, Zuordnung (bei offenen Buchungen der stärkste Vorschlag als
|
||||
* Text) und der Knopf „Zuordnen“ bzw. „Ändern“. Vorgemerkte Buchungen (PDNG) tragen „vorgemerkt“. Die Daten kommen
|
||||
* vom Aufrufer (useFinanceTransactions), damit Seite und Zuordnen-Dialog dieselbe Liste benutzen.
|
||||
* onAssign(buchung) öffnet den Dialog. Schmal (Karte bis 640 px) wird jede Zeile ein Raster: Datum und Betrag /
|
||||
* Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
|
||||
* onAssign(buchung) öffnet den Dialog. Schmal (Wrapper .fin-body bis 640 px) wird jede Zeile ein Raster: Datum und
|
||||
* Betrag / Gegenpartei und Verwendungszweck / Zuordnung und Knopf. Beträge mit „−“ stehen in Rot (fin-neg).
|
||||
*/
|
||||
export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -24,16 +24,13 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
||||
let body
|
||||
if (!loaded && loading) {
|
||||
body = (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Buchungen werden geladen …</p>
|
||||
</div>
|
||||
<div className="loading" role="status">Buchungen werden geladen …</div>
|
||||
)
|
||||
} else if (!loaded) {
|
||||
body = (
|
||||
<div className="fin-load-error">
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
|
||||
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
|
||||
<p className="msg warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
|
||||
<Button onClick={onReload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)
|
||||
} else if (rows.length === 0) {
|
||||
@@ -41,7 +38,7 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
||||
} else {
|
||||
body = (
|
||||
<div className="fin-table-wrap">
|
||||
<table className="ui-table fin-tx" role="table">
|
||||
<table className="table fin-tx" role="table">
|
||||
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
@@ -62,7 +59,12 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
||||
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
|
||||
)}
|
||||
</td>
|
||||
<td role="cell" className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}`}>{row.amountText}</td>
|
||||
<td
|
||||
role="cell"
|
||||
className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}${isNegText(row.amountText) ? ' fin-neg' : ''}`}
|
||||
>
|
||||
{row.amountText}
|
||||
</td>
|
||||
<td role="cell" className="fin-tx-assign">
|
||||
<div className="fin-tx-badges">
|
||||
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
|
||||
@@ -73,14 +75,14 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
||||
</td>
|
||||
<td role="cell" className="fin-tx-action">
|
||||
{row.action && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={row.action === 'assign' ? 'default' : 'ghost'}
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => onAssign(row.tx)}
|
||||
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
|
||||
>
|
||||
{row.actionLabel}
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -93,20 +95,22 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
||||
|
||||
return (
|
||||
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
|
||||
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
|
||||
{loaded && error && (
|
||||
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
|
||||
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
|
||||
</p>
|
||||
)}
|
||||
{stripeFehler && (
|
||||
<p className="fin-hint fin-hint-warn fin-notice" role="status">
|
||||
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
|
||||
<Card title="Buchungen">
|
||||
<div className="fin-body">
|
||||
{loaded && error && (
|
||||
<p {...noticeProps('warn', 'fin-notice')}>
|
||||
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
|
||||
<button type="button" className="link" onClick={onReload} disabled={loading}>Erneut versuchen</button>
|
||||
</p>
|
||||
)}
|
||||
{stripeFehler && (
|
||||
<p className="msg warn fin-notice" role="status">
|
||||
{stripeFehler}
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { Button } from '../ui'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import { Button, Dialog, Field } from '../ui'
|
||||
|
||||
const FIELDS = ['provider', 'purpose', 'cost', 'costInterval', 'startDate', 'termMonths', 'renewalMonths', 'noticeValue', 'noticeUnit', 'note']
|
||||
const NUMBERS = ['termMonths', 'renewalMonths', 'noticeValue']
|
||||
@@ -31,6 +31,8 @@ export default function ContractForm({ contract, onSave, onClose }) {
|
||||
const [files, setFiles] = useState([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const errorRef = useRef(null)
|
||||
useScrollIntoView(errorRef, error)
|
||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
|
||||
const submit = async (e) => {
|
||||
@@ -47,60 +49,59 @@ export default function ContractForm({ contract, onSave, onClose }) {
|
||||
}
|
||||
|
||||
const input = (key, label, props = {}) => (
|
||||
<div className="form-group">
|
||||
<label className="form-label">{label}</label>
|
||||
<input className="form-control" name={key} value={form[key] || ''} onChange={set(key)} {...props} />
|
||||
</div>
|
||||
<Field label={label} htmlFor={`contract-${key}`}>
|
||||
<input className="input" id={`contract-${key}`} name={key} value={form[key] || ''} onChange={set(key)} {...props} />
|
||||
</Field>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">{contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}</h2>
|
||||
{error && <div className="legal-error">{error}</div>}
|
||||
<form onSubmit={submit}>
|
||||
{input('provider', 'Anbieter', { required: true })}
|
||||
{input('purpose', 'Wofür')}
|
||||
<div className="legal-form-row">
|
||||
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
|
||||
<div className="form-group">
|
||||
<label className="form-label">pro</label>
|
||||
<select className="form-control" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
|
||||
<option value="month">Monat</option>
|
||||
<option value="year">Jahr</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
|
||||
<div className="legal-form-row">
|
||||
{input('termMonths', 'Laufzeit (Monate, 0 = jederzeit kündbar)', { type: 'number', min: 0 })}
|
||||
{input('renewalMonths', 'Verlängert sich um (Monate, 0 = endet)', { type: 'number', min: 0 })}
|
||||
</div>
|
||||
<div className="legal-form-row">
|
||||
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Einheit</label>
|
||||
<select className="form-control" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
|
||||
<option value="months">Monate</option>
|
||||
<option value="days">Tage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Notiz</label>
|
||||
<textarea className="form-control" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Dateien hinzufügen</label>
|
||||
<input type="file" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
|
||||
<Button type="button" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Dialog
|
||||
as="form"
|
||||
wide
|
||||
title={contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
||||
busy={saving}
|
||||
onClose={onClose}
|
||||
onSubmit={submit}
|
||||
footer={
|
||||
<>
|
||||
<span className="grow" />
|
||||
<Button type="button" onClick={onClose} disabled={saving}>Abbrechen</Button>
|
||||
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert" ref={errorRef}>{error}</div>}
|
||||
{input('provider', 'Anbieter', { required: true })}
|
||||
{input('purpose', 'Wofür')}
|
||||
<div className="field-row">
|
||||
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
|
||||
<Field label="pro" htmlFor="contract-costInterval">
|
||||
<select className="select" id="contract-costInterval" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
|
||||
<option value="month">Monat</option>
|
||||
<option value="year">Jahr</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
|
||||
<div className="field-row">
|
||||
{input('termMonths', 'Laufzeit (Monate, 0 = jederzeit kündbar)', { type: 'number', min: 0 })}
|
||||
{input('renewalMonths', 'Verlängert sich um (Monate, 0 = endet)', { type: 'number', min: 0 })}
|
||||
</div>
|
||||
<div className="field-row">
|
||||
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
|
||||
<Field label="Einheit" htmlFor="contract-noticeUnit">
|
||||
<select className="select" id="contract-noticeUnit" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
|
||||
<option value="months">Monate</option>
|
||||
<option value="days">Tage</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Notiz" htmlFor="contract-note">
|
||||
<textarea className="textarea" id="contract-note" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
|
||||
</Field>
|
||||
<Field label="Dateien hinzufügen" htmlFor="contract-files">
|
||||
<input type="file" className="legal-file" id="contract-files" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Badge, Button, Card } from '../ui'
|
||||
import { Badge, Card } from '../ui'
|
||||
import { currentVersion, formatDate } from '../../lib/legal'
|
||||
import VersionForm from './VersionForm'
|
||||
import { FileLinks } from './legalUi'
|
||||
@@ -21,8 +21,8 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="legal-grid">
|
||||
{actionError && <div className="legal-error">{actionError}</div>}
|
||||
<div className="legal-docs">
|
||||
{actionError && <div className="msg err legal-msg" role="alert">{actionError}</div>}
|
||||
{documents.map((doc) => {
|
||||
const own = versions.filter((v) => v.documentKey === doc.key).sort(byNewest)
|
||||
const current = currentVersion(own)
|
||||
@@ -33,9 +33,14 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
|
||||
key={doc.$id}
|
||||
className="legal-doc"
|
||||
title={doc.title}
|
||||
actions={<Button size="sm" onClick={() => setFormFor(doc)}>Neue Version</Button>}
|
||||
actions={<button type="button" className="link" onClick={() => setFormFor(doc)}>Neue Version</button>}
|
||||
>
|
||||
<div className="legal-doc-summary" onClick={() => setOpen(expanded ? null : doc.key)} role="button">
|
||||
<button
|
||||
type="button"
|
||||
className="legal-doc-summary"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpen(expanded ? null : doc.key)}
|
||||
>
|
||||
{current ? (
|
||||
<Badge tone="ok">{current.version} · gilt seit {formatDate(current.validFrom)}</Badge>
|
||||
) : (
|
||||
@@ -44,23 +49,23 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
|
||||
{drafts.map((d) => (
|
||||
<Badge key={d.$id} tone="muted">Entwurf {d.version}</Badge>
|
||||
))}
|
||||
<span className="faint legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
|
||||
</div>
|
||||
<span className="legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
|
||||
</button>
|
||||
{doc.liveUrl && (
|
||||
<a href={doc.liveUrl} target="_blank" rel="noreferrer" className="legal-live">Live-Seite öffnen</a>
|
||||
<a href={doc.liveUrl} target="_blank" rel="noreferrer" className="link legal-live">Live-Seite öffnen</a>
|
||||
)}
|
||||
{expanded && (
|
||||
<ol className="legal-history">
|
||||
{own.length === 0 && <li className="faint">Noch keine Versionen eingetragen.</li>}
|
||||
{own.map((v) => (
|
||||
<li key={v.$id} className={`legal-version legal-version-${v.status}`}>
|
||||
<li key={v.$id} className={v.status === 'superseded' ? 'legal-version is-superseded' : 'legal-version'}>
|
||||
<div className="legal-version-head">
|
||||
<strong>{v.version}</strong>
|
||||
<span>{formatDate(v.validFrom)} – {v.status === 'current' ? 'heute' : formatDate(v.validTo)}</span>
|
||||
<span className="muted">{formatDate(v.validFrom)} – {v.status === 'current' ? 'heute' : formatDate(v.validTo)}</span>
|
||||
{v.status === 'current' && <Badge tone="ok">gültig</Badge>}
|
||||
{v.status === 'draft' && <Badge tone="muted">Entwurf</Badge>}
|
||||
{v.status === 'draft' && (
|
||||
<Button size="sm" onClick={() => promote(v)}>Als gültig markieren</Button>
|
||||
<button type="button" className="link" onClick={() => promote(v)}>Als gültig markieren</button>
|
||||
)}
|
||||
</div>
|
||||
{v.changeNote && <div className="legal-note">{v.changeNote}</div>}
|
||||
|
||||
@@ -1,39 +1,12 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Button, EmptyState } from '../ui'
|
||||
import { Badge, Button, EmptyState } from '../ui'
|
||||
import { deadlineTone, formatDate, nextCancellationDate } from '../../lib/legal'
|
||||
import { costLabel, termLabel, noticeLabel, deadlineView } from '../../lib/legalView'
|
||||
import { isRowActivation } from '../../lib/rowClick'
|
||||
import ContractForm from './ContractForm'
|
||||
import { FileLinks, todayIso } from './legalUi'
|
||||
|
||||
const TONE_ORDER = { red: 0, orange: 1, normal: 2, missing: 3, grey: 4 }
|
||||
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
|
||||
function costLabel(c) {
|
||||
if (c.cost === null || c.cost === undefined) return '–'
|
||||
return `${euro.format(c.cost)}/${c.costInterval === 'year' ? 'Jahr' : 'Monat'}`
|
||||
}
|
||||
|
||||
function termLabel(c) {
|
||||
if (c.termMonths === null || c.termMonths === undefined) return '–'
|
||||
if (c.termMonths === 0) return 'jederzeit kündbar'
|
||||
const renew = c.renewalMonths ? `, dann je ${c.renewalMonths} Monate` : ', endet danach'
|
||||
return `${c.termMonths} Monate${renew}`
|
||||
}
|
||||
|
||||
function noticeLabel(c) {
|
||||
if (c.noticeValue === null || c.noticeValue === undefined) return '–'
|
||||
return `${c.noticeValue} ${c.noticeUnit === 'days' ? 'Tage' : 'Monate'}`
|
||||
}
|
||||
|
||||
function deadlineLabel(r) {
|
||||
switch (r.state) {
|
||||
case 'active': return `kündbar bis ${formatDate(r.noticeBy)} (Periode endet ${formatDate(r.periodEnd)})`
|
||||
case 'anytime': return 'jederzeit kündbar'
|
||||
case 'ends': return `endet am ${formatDate(r.periodEnd)}`
|
||||
case 'ended': return `beendet seit ${formatDate(r.periodEnd)}`
|
||||
case 'cancelled': return r.periodEnd ? `gekündigt zum ${formatDate(r.periodEnd)}` : 'gekündigt'
|
||||
default: return 'Fristen fehlen'
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProviderContractsTab({ contracts, fileNames, saveContract, deleteContract }) {
|
||||
const [open, setOpen] = useState(null)
|
||||
@@ -77,55 +50,83 @@ export default function ProviderContractsTab({ contracts, fileNames, saveContrac
|
||||
<div className="toolbar">
|
||||
<Button variant="primary" onClick={() => setEditing('new')}>Neuer Vertrag</Button>
|
||||
</div>
|
||||
{actionError && <div className="legal-error">{actionError}</div>}
|
||||
{actionError && <div className="msg err legal-msg" role="alert">{actionError}</div>}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title="Noch keine Verträge eingetragen" />
|
||||
) : (
|
||||
<table className="ui-table legal-contracts">
|
||||
<table className="table legal-contracts">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Anbieter</th><th>Wofür</th><th>Kosten</th><th>Laufzeit</th><th>Kündigungsfrist</th><th>Nächster Termin</th>
|
||||
<th>Anbieter</th>
|
||||
<th className="hide-sm">Wofür</th>
|
||||
<th className="hide-sm">Kosten</th>
|
||||
<th className="hide-sm">Laufzeit</th>
|
||||
<th className="hide-sm">Kündigungsfrist</th>
|
||||
<th>Nächster Termin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ c, result, tone }) => (
|
||||
<Fragment key={c.$id}>
|
||||
<tr className={`legal-tone-${tone}`} onClick={() => setOpen(open === c.$id ? null : c.$id)}>
|
||||
<td><strong>{c.provider}</strong></td>
|
||||
<td>{c.purpose || '–'}</td>
|
||||
<td>{costLabel(c)}</td>
|
||||
<td>{termLabel(c)}</td>
|
||||
<td>{noticeLabel(c)}</td>
|
||||
<td className="legal-deadline">{deadlineLabel(result)}</td>
|
||||
</tr>
|
||||
{open === c.$id && (
|
||||
<tr className="legal-detail">
|
||||
<td colSpan={6}>
|
||||
{c.startDate && <div>Beginn: {formatDate(c.startDate)}</div>}
|
||||
{c.note && <div className="legal-note">{c.note}</div>}
|
||||
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
|
||||
<div className="flex gap-2 legal-actions">
|
||||
<Button size="sm" onClick={() => setEditing(c)}>Bearbeiten</Button>
|
||||
{!c.cancelledAt && cancelling !== c.$id && (
|
||||
<Button size="sm" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
|
||||
Als gekündigt markieren
|
||||
</Button>
|
||||
)}
|
||||
{cancelling === c.$id && (
|
||||
<span className="flex gap-2 items-center">
|
||||
<label>Endet am</label>
|
||||
<input type="date" className="form-control" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
|
||||
<Button size="sm" variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
|
||||
<Button size="sm" onClick={() => setCancelling(null)}>Abbrechen</Button>
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="danger" onClick={() => remove(c)}>Löschen</Button>
|
||||
{rows.map(({ c, result }) => {
|
||||
const view = deadlineView(result, today)
|
||||
const toggle = () => setOpen(open === c.$id ? null : c.$id)
|
||||
return (
|
||||
<Fragment key={c.$id}>
|
||||
<tr className="row" onClick={(e) => { if (isRowActivation(e)) toggle() }}>
|
||||
<td>
|
||||
<div className="cell-title">
|
||||
<button type="button" className="link" aria-expanded={open === c.$id} onClick={toggle}>{c.provider}</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hide-sm">{c.purpose || '–'}</td>
|
||||
<td className="hide-sm">{costLabel(c)}</td>
|
||||
<td className="hide-sm">{termLabel(c)}</td>
|
||||
<td className="hide-sm">{noticeLabel(c)}</td>
|
||||
<td>
|
||||
{view.badge ? (
|
||||
<>
|
||||
<Badge tone={view.badge}>{view.lead}</Badge>
|
||||
<span className="muted"> {view.rest}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={view.muted ? 'muted' : undefined}>{view.text}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{open === c.$id && (
|
||||
<tr className="legal-detail">
|
||||
<td colSpan={6}>
|
||||
<dl className="kv legal-facts">
|
||||
<dt>Wofür</dt><dd>{c.purpose || '–'}</dd>
|
||||
<dt>Kosten</dt><dd>{costLabel(c)}</dd>
|
||||
<dt>Laufzeit</dt><dd>{termLabel(c)}</dd>
|
||||
<dt>Kündigungsfrist</dt><dd>{noticeLabel(c)}</dd>
|
||||
</dl>
|
||||
{c.startDate && <div>Beginn: {formatDate(c.startDate)}</div>}
|
||||
{c.note && <div className="legal-note">{c.note}</div>}
|
||||
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
|
||||
<div className="legal-actions">
|
||||
<button type="button" className="link" onClick={() => setEditing(c)}>Bearbeiten</button>
|
||||
{!c.cancelledAt && cancelling !== c.$id && (
|
||||
<button type="button" className="link" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
|
||||
Als gekündigt markieren
|
||||
</button>
|
||||
)}
|
||||
{cancelling === c.$id && (
|
||||
<span className="legal-cancel">
|
||||
<label htmlFor={`cancel-${c.$id}`}>Endet am</label>
|
||||
<input id={`cancel-${c.$id}`} type="date" className="input" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
|
||||
<Button variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
|
||||
<Button onClick={() => setCancelling(null)}>Abbrechen</Button>
|
||||
</span>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => remove(c)}>Löschen</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { Button } from '../ui'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import { Button, Dialog, Field } from '../ui'
|
||||
import { todayIso } from './legalUi'
|
||||
|
||||
export default function VersionForm({ title, onSave, onClose }) {
|
||||
@@ -8,6 +8,8 @@ export default function VersionForm({ title, onSave, onClose }) {
|
||||
const [files, setFiles] = useState([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const errorRef = useRef(null)
|
||||
useScrollIntoView(errorRef, error)
|
||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
|
||||
const submit = async (e) => {
|
||||
@@ -24,41 +26,39 @@ export default function VersionForm({ title, onSave, onClose }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Neue Version: {title}</h2>
|
||||
{error && <div className="legal-error">{error}</div>}
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Version</label>
|
||||
<input className="form-control" name="version" value={form.version} onChange={set('version')} placeholder="z. B. v6" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-control" name="status" value={form.status} onChange={set('status')}>
|
||||
<option value="current">Gültig</option>
|
||||
<option value="draft">Entwurf (noch nicht live)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Gültig ab</label>
|
||||
<input className="form-control" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Was hat sich geändert?</label>
|
||||
<textarea className="form-control" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Dateien (PDF, HTML, Bild, Word)</label>
|
||||
<input type="file" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
|
||||
<Button type="button" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
as="form"
|
||||
title={`Neue Version: ${title}`}
|
||||
busy={saving}
|
||||
onClose={onClose}
|
||||
onSubmit={submit}
|
||||
footer={
|
||||
<>
|
||||
<span className="grow" />
|
||||
<Button type="button" onClick={onClose} disabled={saving}>Abbrechen</Button>
|
||||
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert" ref={errorRef}>{error}</div>}
|
||||
<Field label="Version" htmlFor="version-version">
|
||||
<input className="input" id="version-version" name="version" value={form.version} onChange={set('version')} placeholder="z. B. v6" required />
|
||||
</Field>
|
||||
<Field label="Status" htmlFor="version-status">
|
||||
<select className="select" id="version-status" name="status" value={form.status} onChange={set('status')}>
|
||||
<option value="current">Gültig</option>
|
||||
<option value="draft">Entwurf (noch nicht live)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Gültig ab" htmlFor="version-validFrom">
|
||||
<input className="input" id="version-validFrom" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
|
||||
</Field>
|
||||
<Field label="Was hat sich geändert?" htmlFor="version-changeNote">
|
||||
<textarea className="textarea" id="version-changeNote" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
|
||||
</Field>
|
||||
<Field label="Dateien (PDF, HTML, Bild, Word)" htmlFor="version-files">
|
||||
<input type="file" className="legal-file" id="version-files" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export function FileLinks({ fileIds = [], fileNames = {} }) {
|
||||
<span className="legal-files">
|
||||
{fileIds.map((id) => (
|
||||
<span key={id}>
|
||||
<a href={fileUrl(id)} target="_blank" rel="noreferrer">{fileNames[id] || 'Datei'}</a>
|
||||
<a href={fileUrl(id)} target="_blank" rel="noreferrer" className="link">{fileNames[id] || 'Datei'}</a>
|
||||
{' '}
|
||||
<a href={fileDownloadUrl(id)} className="faint">(herunterladen)</a>
|
||||
<a href={fileDownloadUrl(id)} className="link legal-dl">(herunterladen)</a>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Button, AutocompleteInput } from '../ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Dialog, Field, Button, AutocompleteInput } from '../ui'
|
||||
import { useCustomers } from '../../hooks/useCustomers'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import { createOffer, getOfferContext } from '../../lib/teamApi'
|
||||
import { customerPickLabel, findCustomerByLabel } from '../../lib/chatFormat'
|
||||
import { createdMessage, evaluateOfferForm, offerConfirm } from '../../lib/offerFormat'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
|
||||
@@ -29,6 +28,8 @@ export default function OfferForm({ fixedCustomerId, fixedCustomerName, onCreate
|
||||
const [ctx, setCtx] = useState(null) // { customerId, data } | { customerId, error }
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const errorRef = useRef(null)
|
||||
useScrollIntoView(errorRef, error)
|
||||
|
||||
useEffect(() => {
|
||||
setCtx(null)
|
||||
@@ -83,62 +84,70 @@ export default function OfferForm({ fixedCustomerId, fixedCustomerName, onCreate
|
||||
}
|
||||
|
||||
let previewBox
|
||||
if (!customerId) previewBox = <div>Bitte einen Kunden aus der Liste wählen.</div>
|
||||
else if (ctx?.error && ctx.customerId === customerId) previewBox = <div className="area-danger">{ctx.error}</div>
|
||||
else if (!ready) previewBox = <div>Lade Kunde…</div>
|
||||
if (!customerId) previewBox = <p>Bitte einen Kunden aus der Liste wählen.</p>
|
||||
else if (ctx?.error && ctx.customerId === customerId) previewBox = <p className="msg err">{ctx.error}</p>
|
||||
else if (!ready) previewBox = <p>Lade Kunde…</p>
|
||||
else {
|
||||
previewBox = (
|
||||
<>
|
||||
{evaluation.errors.map((l) => <div key={l} className="area-danger">{l}</div>)}
|
||||
{evaluation.previewLines.map((l) => <div key={l}>{l}</div>)}
|
||||
{evaluation.errors.map((l) => <p key={l} className="msg err">{l}</p>)}
|
||||
{evaluation.previewLines.map((l) => <p key={l}>{l}</p>)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="offer-form">
|
||||
<h3 style={{ marginTop: 0 }}>Neues Angebot</h3>
|
||||
<div className="credit-calc">
|
||||
<label style={{ gridColumn: '1 / -1' }}>Kunde
|
||||
{fixedCustomerId
|
||||
? <strong style={{ color: 'var(--text)' }}>{fixedCustomerName || 'Kunde'}</strong>
|
||||
: <AutocompleteInput value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
|
||||
</label>
|
||||
<label>Produkt
|
||||
<select className="form-control" value={productKey} onChange={(e) => setProductKey(e.target.value)}>
|
||||
<Dialog
|
||||
title="Neues Angebot"
|
||||
onClose={onCancel}
|
||||
busy={busy}
|
||||
footer={
|
||||
<>
|
||||
<span className="grow" />
|
||||
<Button type="button" onClick={onCancel} disabled={busy}>Abbrechen</Button>
|
||||
<Button variant="primary" type="button" onClick={save} disabled={!evaluation || evaluation.errors.length > 0 || busy}>
|
||||
{busy ? 'Speichert …' : evaluation?.saveLabel || 'Angebot anlegen'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Kunde" htmlFor={fixedCustomerId ? undefined : 'offer-customer'}>
|
||||
{fixedCustomerId
|
||||
? <p className="strong">{fixedCustomerName || 'Kunde'}</p>
|
||||
: <AutocompleteInput id="offer-customer" value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
|
||||
</Field>
|
||||
<div className="field-row">
|
||||
<Field label="Produkt" htmlFor="offer-product">
|
||||
<select id="offer-product" className="select" value={productKey} onChange={(e) => setProductKey(e.target.value)}>
|
||||
<option value="wartung">Wartungs-Abo</option>
|
||||
<option value="hosting">Hosting-Abo</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Preis pro Monat im Angebot (€)
|
||||
<input className="form-control" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||
</label>
|
||||
<label>für
|
||||
<select className="form-control" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
|
||||
</Field>
|
||||
<Field label="Preis pro Monat im Angebot (€)" htmlFor="offer-price">
|
||||
<input id="offer-price" className="input" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<Field label="für" htmlFor="offer-months">
|
||||
<select id="offer-months" className="select" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
|
||||
{MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Angebot gültig bis
|
||||
<input className="form-control" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
|
||||
</label>
|
||||
</Field>
|
||||
<Field label="Angebot gültig bis" htmlFor="offer-valid-until">
|
||||
<input id="offer-valid-until" className="input" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="credit-field">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)
|
||||
<input className="form-control" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
<label className="credit-field">Interne Notiz (optional, sieht nur das Team)
|
||||
<input className="form-control" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
|
||||
<Field label="Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)" htmlFor="offer-note">
|
||||
<input id="offer-note" className="input" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Interne Notiz (optional, sieht nur das Team)" htmlFor="offer-internal-note">
|
||||
<input id="offer-internal-note" className="input" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
|
||||
</Field>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} /> Kunden per E-Mail über das Angebot informieren
|
||||
</label>
|
||||
<div className="area-summary">{previewBox}</div>
|
||||
<div className="area-inline">
|
||||
<Button variant="primary" onClick={save} disabled={!evaluation || evaluation.errors.length > 0 || busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : evaluation?.saveLabel || 'Angebot anlegen'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>Abbrechen</Button>
|
||||
</div>
|
||||
{error && <div className="area-error">{error}</div>}
|
||||
</Card>
|
||||
<div>{previewBox}</div>
|
||||
{error && <p className="msg err" role="alert" ref={errorRef}>{error}</p>}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button } from '../ui'
|
||||
import { Badge, Button, EmptyState, Tabs } from '../ui'
|
||||
import { listOffers, notifyOffer, withdrawOffer } from '../../lib/teamApi'
|
||||
import { discountLine, offerStatus } from '../../lib/offerFormat'
|
||||
import { discountLine, filterOffers, offerMailLine, offerRowActions, offerStatus } from '../../lib/offerFormat'
|
||||
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
// Liste der Abo-Angebote (Filter "Offen & aktiv"/"Alle") wie im frueheren Admin-Panel; optional nur ein Kunde
|
||||
// Liste der Abo-Angebote (Reiter "Offen & aktiv"/"Alle") als Tabelle; optional nur ein Kunde
|
||||
export default function OfferList({ customerId, reloadKey, onMessage, onChanged }) {
|
||||
const [filter, setFilter] = useState('open')
|
||||
const [offers, setOffers] = useState(null)
|
||||
@@ -51,69 +49,75 @@ export default function OfferList({ customerId, reloadKey, onMessage, onChanged
|
||||
}
|
||||
}
|
||||
|
||||
const shown = (offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
|
||||
const shown = filterOffers(offers, filter)
|
||||
return (
|
||||
<div>
|
||||
<div className="area-filter seg">
|
||||
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen & aktiv</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>}
|
||||
{!offers && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
|
||||
{offers && (
|
||||
<div className="area-rows">
|
||||
<div className="area-row offer-row head"><span>Angelegt</span><span>Kunde</span><span>Angebot</span><span>Gültig bis</span><span>Status</span></div>
|
||||
{shown.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</span></div>}
|
||||
<Tabs tabs={[{ id: 'open', label: 'Offen & aktiv' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
{!offers && !error && <div className="loading">Wird geladen …</div>}
|
||||
{offers && shown.length === 0 && (
|
||||
<EmptyState title={filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} />
|
||||
)}
|
||||
{offers && shown.length > 0 && (
|
||||
<table className="table table-wrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="hide-md">Angelegt</th>
|
||||
<th className="col-name">Kunde</th>
|
||||
<th className="col-offer">Angebot</th>
|
||||
<th className="hide-sm">Gültig bis</th>
|
||||
<th className="col-status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((o) => {
|
||||
const st = offerStatus(o.status)
|
||||
const discount = discountLine(o)
|
||||
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
|
||||
const mail = offerMailLine(o)
|
||||
const { sendLabel, canWithdraw } = offerRowActions(o)
|
||||
return (
|
||||
<div key={o.id} className="area-row offer-row">
|
||||
<span>{fmtDateTime(o.createdAt)}<br /><span className="area-muted">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</span></span>
|
||||
<span>
|
||||
<strong>{o.customerName}</strong>
|
||||
{o.customerEmail && <><br /><span className="area-muted">{o.customerEmail}</span></>}
|
||||
{o.customerCode && <><br /><span className="area-muted">Kd.-Nr. {o.customerCode}</span></>}
|
||||
</span>
|
||||
<span>
|
||||
<span className="area-muted">{productLabelOf(o.productKey)}</span><br />{o.text}
|
||||
<br /><span className="area-muted">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</span>
|
||||
{discount && <><br /><span className="area-muted">{discount}</span></>}
|
||||
{o.internalNote && <><br /><span className="area-muted"><em>intern: {o.internalNote}</em></span></>}
|
||||
</span>
|
||||
<span>{o.scope === 'laufend' ? <span className="area-muted">laufendes Abo</span> : fmtDate(o.validUntil)}</span>
|
||||
<span>
|
||||
<tr key={o.id}>
|
||||
<td className="hide-md">
|
||||
{fmtDateTime(o.createdAt)}
|
||||
<div className="cell-sub">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-title">{o.customerName}</div>
|
||||
{o.customerEmail && <div className="cell-sub">{o.customerEmail}</div>}
|
||||
{o.customerCode && <div className="cell-sub">Kd.-Nr. {o.customerCode}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-sub">{productLabelOf(o.productKey)}</div>
|
||||
{o.text}
|
||||
<div className="cell-sub">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</div>
|
||||
{discount && <div className="cell-sub">{discount}</div>}
|
||||
{o.internalNote && <div className="cell-sub"><em>intern: {o.internalNote}</em></div>}
|
||||
</td>
|
||||
<td className="hide-sm nowrap">{o.scope === 'laufend' ? <span className="muted">laufendes Abo</span> : fmtDate(o.validUntil)}</td>
|
||||
<td>
|
||||
<Badge tone={st.tone}>{st.label}</Badge>
|
||||
{sub && <><br /><span className="area-muted">{sub}</span></>}
|
||||
<br /><span className="area-muted">Stripe:</span>{' '}
|
||||
{o.stripeCouponUrl ? <a href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
|
||||
{o.stripeSubscriptionUrl && <> · <a href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
|
||||
{o.notifiedAt
|
||||
? <><br /><span className="area-muted">E-Mail gesendet {fmtDateTime(o.notifiedAt)}</span></>
|
||||
: o.mailSendable
|
||||
? <><br /><span className="area-warn">E-Mail nicht verschickt</span></>
|
||||
: o.status === 'offen' && !o.customerEmail ? <><br /><span className="area-warn">Keine E-Mail-Adresse hinterlegt</span></> : null}
|
||||
{(['offen', 'abgelaufen'].includes(o.status) || o.mailSendable) && (
|
||||
<span className="area-inline" style={{ marginTop: 6 }}>
|
||||
{o.mailSendable && (
|
||||
<Button size="sm" variant="ghost" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>
|
||||
{o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}
|
||||
</Button>
|
||||
{sub && <div className="cell-sub">{sub}</div>}
|
||||
<div className="cell-sub">
|
||||
Stripe: {o.stripeCouponUrl ? <a className="link" href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
|
||||
{o.stripeSubscriptionUrl && <> · <a className="link" href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
|
||||
</div>
|
||||
{mail && <div className="cell-sub">{mail.tone === 'warn' ? <span className="msg warn">{mail.text}</span> : mail.text}</div>}
|
||||
{(sendLabel || canWithdraw) && (
|
||||
<div className="cell-actions">
|
||||
{sendLabel && (
|
||||
<button type="button" className="link" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>{sendLabel}</button>
|
||||
)}
|
||||
{['offen', 'abgelaufen'].includes(o.status) && (
|
||||
<Button size="sm" variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>
|
||||
)}
|
||||
</span>
|
||||
{canWithdraw && <Button variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { FaPlus } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import OfferList from './OfferList'
|
||||
import OfferForm from './OfferForm'
|
||||
@@ -13,23 +12,19 @@ export default function OffersView({ customerId, customerName }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{message && <div className="area-message">{message}</div>}
|
||||
{!showForm && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}><FaPlus /> Neues Angebot</Button>
|
||||
</div>
|
||||
)}
|
||||
{showForm && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<OfferForm
|
||||
fixedCustomerId={customerId}
|
||||
fixedCustomerName={customerName}
|
||||
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{message && <p className="msg ok mb-2" role="status">{message}</p>}
|
||||
<div className="toolbar">
|
||||
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}>Neues Angebot</Button>
|
||||
</div>
|
||||
<OfferList customerId={customerId} reloadKey={reloadKey} onMessage={setMessage} onChanged={reload} />
|
||||
{showForm && (
|
||||
<OfferForm
|
||||
fixedCustomerId={customerId}
|
||||
fixedCustomerName={customerName}
|
||||
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Tonabbildung der Projekt-Bausteine: reine Zuordnung, ohne JSX (Test: tests/unit/projectTones.test.mjs).
|
||||
Die Töne sind die Namen der Logik aus Badge.jsx: ok, warn, info, danger, muted, plain.
|
||||
Jede Zuordnung ist eine Map, kein Objekt: sonst liefert ein Schlüssel wie 'constructor' eine Funktion. */
|
||||
|
||||
// previewStatus().color (lib/previewView.js)
|
||||
const PREVIEW_COLOR_TONES = new Map([
|
||||
['green', 'ok'],
|
||||
['yellow', 'warn'],
|
||||
['red', 'danger'],
|
||||
['grey', 'plain'],
|
||||
])
|
||||
|
||||
// previewState der Services-Antwort (online, building, starting, sleeping, failed, off)
|
||||
const PREVIEW_STATE_TONES = new Map([
|
||||
['online', 'ok'],
|
||||
['building', 'warn'],
|
||||
['starting', 'warn'],
|
||||
['sleeping', 'warn'],
|
||||
['failed', 'danger'],
|
||||
])
|
||||
|
||||
// analyseStatus().color (lib/analyseView.js)
|
||||
const ANALYSE_COLOR_TONES = new Map([
|
||||
['green', 'ok'],
|
||||
['red', 'danger'],
|
||||
])
|
||||
|
||||
// status und provisioningStatus eines Projekts, wie die Farbtabelle STATUS_COLORS in AssignedProjectCard heute.
|
||||
// 'deployed' ist absichtlich nicht dabei: es blieb dort in der Standardfarbe (grau).
|
||||
const STATUS_VALUE_TONES = new Map([
|
||||
['ready', 'ok'],
|
||||
['live', 'ok'],
|
||||
['online', 'ok'],
|
||||
['provisioning', 'warn'],
|
||||
['pending', 'warn'],
|
||||
['building', 'info'],
|
||||
['error', 'danger'],
|
||||
['failed', 'danger'],
|
||||
])
|
||||
|
||||
export function previewColorTone(color) {
|
||||
return PREVIEW_COLOR_TONES.get(color) ?? 'plain'
|
||||
}
|
||||
|
||||
export function previewStateTone(state) {
|
||||
return PREVIEW_STATE_TONES.get(state) ?? 'plain'
|
||||
}
|
||||
|
||||
export function analyseColorTone(color) {
|
||||
return ANALYSE_COLOR_TONES.get(color) ?? 'muted'
|
||||
}
|
||||
|
||||
export function statusValueTone(value) {
|
||||
return STATUS_VALUE_TONES.get(String(value ?? '').toLowerCase()) ?? 'muted'
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { formatBytes, formatDelta, formatPct } from '../../lib/serverpulsFormat'
|
||||
import { isRowActivation } from '../../lib/rowClick'
|
||||
import Sparkline from './Sparkline'
|
||||
|
||||
const COLS = [
|
||||
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
|
||||
{ key: 'sizeBytes', label: 'Größe', get: (r) => r.sizeBytes },
|
||||
{ key: 'delta7d', label: 'Δ 7 T', get: (r) => r.delta7d },
|
||||
{ key: 'delta30d', label: 'Δ 30 T', get: (r) => r.delta30d },
|
||||
{ key: 'cpuPct', label: 'CPU', get: (r) => r.cpuPct },
|
||||
{ key: 'memBytes', label: 'RAM', get: (r) => r.memBytes },
|
||||
]
|
||||
|
||||
// Das Vorzeichen steckt in d.text, die Richtung färbt den Text (rot = gestiegen, grün = gesunken)
|
||||
function Delta({ pct }) {
|
||||
const d = formatDelta(pct)
|
||||
const arrow = d.dir === 'up' ? '▲ ' : d.dir === 'down' ? '▼ ' : ''
|
||||
return <span className={`sp-delta sp-delta-${d.dir}`}>{arrow}{d.text}</span>
|
||||
return <span className={`sp-delta sp-delta-${d.dir}`}>{d.text}</span>
|
||||
}
|
||||
|
||||
// num: Zahlenspalte (rechts, tabellarische Ziffern), hide: unter 860 px ausgeblendet (Kernspalten bleiben)
|
||||
const COLS = [
|
||||
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
|
||||
{ key: 'sizeBytes', label: 'Größe', num: true, get: (r) => r.sizeBytes, cell: (r) => formatBytes(r.sizeBytes) },
|
||||
{ key: 'delta7d', label: 'Δ 7 T', num: true, get: (r) => r.delta7d, cell: (r) => <Delta pct={r.delta7d} /> },
|
||||
{ key: 'delta30d', label: 'Δ 30 T', num: true, hide: true, get: (r) => r.delta30d, cell: (r) => <Delta pct={r.delta30d} /> },
|
||||
{ key: 'cpuPct', label: 'CPU', num: true, hide: true, get: (r) => r.cpuPct, cell: (r) => (r.cpuPct === null ? '–' : formatPct(r.cpuPct)) },
|
||||
{ key: 'memBytes', label: 'RAM', num: true, get: (r) => r.memBytes, cell: (r) => formatBytes(r.memBytes) },
|
||||
]
|
||||
|
||||
const cellClass = (c) => [c.num && 'right num', c.hide && 'hide-sm'].filter(Boolean).join(' ') || undefined
|
||||
|
||||
export default function AppsTable({ rows, selectedId, onSelect }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [sort, setSort] = useState({ key: 'sizeBytes', dir: 'desc' })
|
||||
@@ -39,52 +43,71 @@ export default function AppsTable({ rows, selectedId, onSelect }) {
|
||||
const toggle = (key) =>
|
||||
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }))
|
||||
|
||||
// Der Knopf in der ersten Zelle trägt die Tastatur, der Zeilenklick die Maus: isRowActivation filtert Klicks auf Knöpfe aus,
|
||||
// so löst jede Aktivierung onSelect genau einmal aus
|
||||
const pick = (id) => onSelect(selectedId === id ? null : id)
|
||||
|
||||
return (
|
||||
<div className="sp-apps" data-testid="sp-apps">
|
||||
<input
|
||||
className="sp-search"
|
||||
data-testid="sp-apps-search"
|
||||
type="search"
|
||||
placeholder="Projekt suchen …"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="sp-table-wrap">
|
||||
<table className="sp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLS.map((c) => (
|
||||
<th key={c.key} onClick={() => toggle(c.key)} className="sp-sortable" data-sort={c.key}>
|
||||
{c.label}{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}
|
||||
</th>
|
||||
))}
|
||||
<th>30 Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => (
|
||||
<div data-testid="sp-apps">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="input search"
|
||||
data-testid="sp-apps-search"
|
||||
type="search"
|
||||
placeholder="Projekt suchen …"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLS.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cellClass(c)}
|
||||
data-sort={c.key}
|
||||
aria-sort={sort.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
>
|
||||
<button type="button" className="sp-sort" onClick={() => toggle(c.key)}>
|
||||
{c.label}
|
||||
<span aria-hidden="true">{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}</span>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="hide-sm">30 Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => {
|
||||
const selected = selectedId === r.id
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`row${selected ? ' sp-row-active' : ''}`}
|
||||
data-testid="sp-app-row"
|
||||
data-app-id={r.id}
|
||||
className={`sp-row ${selectedId === r.id ? 'sp-row-active' : ''}`}
|
||||
onClick={() => onSelect(selectedId === r.id ? null : r.id)}
|
||||
onClick={(e) => {
|
||||
if (isRowActivation(e)) pick(r.id)
|
||||
}}
|
||||
>
|
||||
<td className="sp-name">{r.name}</td>
|
||||
<td>{formatBytes(r.sizeBytes)}</td>
|
||||
<td><Delta pct={r.delta7d} /></td>
|
||||
<td><Delta pct={r.delta30d} /></td>
|
||||
<td>{r.cpuPct === null ? '–' : formatPct(r.cpuPct)}</td>
|
||||
<td>{formatBytes(r.memBytes)}</td>
|
||||
<td><Sparkline points={r.spark30d} width={90} height={24} /></td>
|
||||
<td className="sp-name">
|
||||
<button type="button" className="link" aria-pressed={selected} onClick={() => pick(r.id)}>
|
||||
{r.name}
|
||||
</button>
|
||||
</td>
|
||||
{COLS.slice(1).map((c) => (
|
||||
<td key={c.key} className={cellClass(c)}>{c.cell(r)}</td>
|
||||
))}
|
||||
<td className="hide-sm"><Sparkline points={r.spark30d} width={90} height={24} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import Stat from './Stat'
|
||||
|
||||
// Altname fuer Stat (wird in P7 geloescht); "icon" wird ignoriert, die Warnfarbe wird zu 'neg'
|
||||
export default function Kpi({ label, value, sub, icon, accent }) {
|
||||
return <Stat label={label} value={value} sub={sub} tone={accent === 'var(--danger)' ? 'neg' : ''} />
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export default function Tabs({ tabs, active, onChange }) {
|
||||
const listRef = useRef(null)
|
||||
|
||||
// Der aktive Reiter gehoert in den sichtbaren Teil der Leiste (Deep-Links ?tab=..., am Handy sind es mehr Reiter als Platz).
|
||||
// Nur die Leiste scrollt, waagerecht und nur wenn noetig; die Seite selbst bewegt sich nicht (anders als bei scrollIntoView).
|
||||
useEffect(() => {
|
||||
const list = listRef.current
|
||||
const tab = list?.querySelector('[role="tab"][aria-selected="true"]')
|
||||
if (!list || !tab) return
|
||||
const l = list.getBoundingClientRect()
|
||||
const t = tab.getBoundingClientRect()
|
||||
if (t.left < l.left || t.right > l.right) {
|
||||
list.scrollLeft += (t.left + t.right) / 2 - (l.left + l.right) / 2
|
||||
}
|
||||
}, [active, tabs.length])
|
||||
|
||||
return (
|
||||
<div className="tabs" role="tablist">
|
||||
<div className="tabs" role="tablist" ref={listRef}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
|
||||
@@ -7,7 +7,6 @@ export { default as Tabs } from './Tabs'
|
||||
export { default as EmptyState } from './EmptyState'
|
||||
export { default as Field } from './Field'
|
||||
export { default as Stat } from './Stat'
|
||||
export { default as Kpi } from './Kpi'
|
||||
export { default as AutocompleteInput } from './AutocompleteInput'
|
||||
export { default as DateTimeInput } from './DateTimeInput'
|
||||
export { default as DurationSelect } from './DurationSelect'
|
||||
|
||||
@@ -10,6 +10,17 @@ export const CREDIT_STATUS = {
|
||||
}
|
||||
export const creditStatus = (status) => CREDIT_STATUS[status] || { tone: 'muted', label: status }
|
||||
|
||||
/** Knopf der Listenzeile: offene und rueckgefragte Antraege werden geprueft, alle anderen nur angesehen */
|
||||
export const creditActionLabel = (status) => (['offen', 'rueckfrage'].includes(status) ? 'Prüfen' : 'Ansehen')
|
||||
|
||||
/** Zweite Zeile unter dem Status: bei genehmigten Antraegen Betrag und Buchungsstand, sonst leer */
|
||||
export const creditStatusNote = (r) =>
|
||||
(r.status === 'genehmigt' ? `${formatCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}` : '')
|
||||
|
||||
/** Zeichen und Ton einer Zeile der automatischen Pruefung (unbekannt: "i" in gedaempftem Ton) */
|
||||
export const checkGlyph = (status) => (status === 'ok' ? '✓' : status === 'warn' ? '!' : 'i')
|
||||
export const checkTone = (status) => (status === 'ok' || status === 'warn' || status === 'info' ? status : 'muted')
|
||||
|
||||
const HISTORY_LABELS = {
|
||||
eingereicht: 'Antrag eingereicht',
|
||||
rueckfrage: 'Rückfrage an den Kunden',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Reine Funktionen der Kundenliste und der Kundenseite: Stufe, Filter, Zähler und Abzeichen.
|
||||
// Ohne Importe, damit Liste, Zähler und Tests dieselbe Logik benutzen.
|
||||
|
||||
export function customerStage(c) {
|
||||
const s = (c?.customerStatus || '').toLowerCase()
|
||||
if (s === 'lead') return 'lead'
|
||||
if (s === 'lost') return 'lost'
|
||||
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
|
||||
}
|
||||
|
||||
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
|
||||
export function isArchived(c) {
|
||||
return Boolean(c?.archived)
|
||||
}
|
||||
|
||||
/** Reiter der Kundenliste in Anzeigereihenfolge. */
|
||||
export const CUSTOMER_TABS = [
|
||||
{ id: 'customer', label: 'Feste Kunden' },
|
||||
{ id: 'lead', label: 'Leads' },
|
||||
{ id: 'archived', label: 'Archiviert' },
|
||||
{ id: 'all', label: 'Alle' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Filtert die Kundenliste nach Reiter und Suchtext. Die Reihenfolge der Eingabe bleibt.
|
||||
* Archivierte erscheinen nur im Reiter `archived`. Die Stufe `lost` erscheint nur im Reiter `all`.
|
||||
*/
|
||||
export function filterCustomers(customers, { stage = 'customer', search = '' } = {}) {
|
||||
const s = String(search || '').toLowerCase()
|
||||
const matchesStage = (c) => {
|
||||
const archived = isArchived(c)
|
||||
if (stage === 'archived') return archived
|
||||
if (archived) return false
|
||||
if (stage === 'all') return true
|
||||
if (stage === 'customer' || stage === 'lead') return customerStage(c) === stage
|
||||
return false
|
||||
}
|
||||
return (customers || []).filter((c) => {
|
||||
if (!matchesStage(c)) return false
|
||||
if (!s) return true
|
||||
return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''}`.toLowerCase().includes(s)
|
||||
})
|
||||
}
|
||||
|
||||
/** Zähler je Reiter, berechnet mit derselben Filterfunktion wie die Liste (beachtet den Suchtext). */
|
||||
export function stageCounts(customers, search = '') {
|
||||
const counts = {}
|
||||
for (const tab of CUSTOMER_TABS) counts[tab.id] = filterCustomers(customers, { stage: tab.id, search }).length
|
||||
return counts
|
||||
}
|
||||
|
||||
const STAGE_LABELS = { customer: 'Fester Kunde', lead: 'Lead', lost: 'Abgesagt' }
|
||||
|
||||
export function stageLabel(stage) {
|
||||
return STAGE_LABELS[stage] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Abzeichen eines Kunden in fester Reihenfolge: Wichtig, Stufe, Archiviert.
|
||||
* Die Stufe „Fester Kunde“ ist kein Abzeichen. Mit `stage: false` entfällt die Stufe (Kundenseite, dort steht sie in der Eyebrow).
|
||||
*/
|
||||
export function customerBadges(c, { stage = true } = {}) {
|
||||
const badges = []
|
||||
if (c?.importantCustomer) badges.push({ key: 'important', label: 'Wichtig', tone: 'warn' })
|
||||
if (stage) {
|
||||
const st = customerStage(c)
|
||||
if (st === 'lead') badges.push({ key: 'lead', label: stageLabel('lead'), tone: 'muted' })
|
||||
else if (st === 'lost') badges.push({ key: 'lost', label: stageLabel('lost'), tone: 'plain' })
|
||||
}
|
||||
if (isArchived(c)) badges.push({ key: 'archived', label: 'Archiviert', tone: 'plain' })
|
||||
return badges
|
||||
}
|
||||
|
||||
/** Kleine Zeile über dem Titel der Kundenseite: „Kundennummer · Stufe“. */
|
||||
export function customerEyebrow(c) {
|
||||
return [String(c?.code || '').trim(), stageLabel(customerStage(c))].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** Titel der Kundenseite: Name, sonst E-Mail, sonst Gedankenstrich. */
|
||||
export function customerTitle(c) {
|
||||
return c?.name || c?.email || '–'
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Prüfung des Mitarbeiter-Formulars (Admin). Rein, unter Node testbar.
|
||||
|
||||
const NEED = 'Name und E-Mail sind erforderlich.'
|
||||
const MIN_PASSWORD = 8
|
||||
|
||||
/**
|
||||
* Prüft das Formular zum Anlegen (isEdit false) oder Bearbeiten (isEdit true).
|
||||
* Gibt den deutschen Fehlertext zurück, null heißt gültig. Beim Bearbeiten
|
||||
* heißt ein leeres Passwort „unverändert“.
|
||||
*/
|
||||
export function validateEmployeeForm(form, isEdit) {
|
||||
if (!form.displayName.trim() || !form.email.trim()) return NEED
|
||||
if (!isEdit && form.password.length < MIN_PASSWORD) {
|
||||
return 'Das Passwort muss mindestens 8 Zeichen haben.'
|
||||
}
|
||||
if (isEdit && form.password && form.password.length < MIN_PASSWORD) {
|
||||
return 'Ein neues Passwort muss mindestens 8 Zeichen haben.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Kleine Darstellungs-Helfer fuer die Finanzen-Seiten (kein React, keine Abhaengigkeit, unter Node testbar).
|
||||
|
||||
const MINUS = '−' // U+2212, das Minus aus financeFormat.js
|
||||
|
||||
/** Wahr, wenn der angezeigte Text mit dem Minuszeichen (U+2212) beginnt. Nur dann wird ein Betrag rot (`fin-neg`). */
|
||||
export function isNegText(text) {
|
||||
return typeof text === 'string' && text.startsWith(MINUS)
|
||||
}
|
||||
|
||||
/** Ton ('ok' | 'warn' | sonst) -> Klasse und Rolle einer Meldung; `extra` wird mit Leerzeichen an die Klasse gehaengt. */
|
||||
export function noticeProps(tone, extra = '') {
|
||||
const base = tone === 'ok' ? 'msg ok' : tone === 'warn' ? 'msg warn' : 'msg muted'
|
||||
return {
|
||||
className: extra ? `${base} ${extra}` : base,
|
||||
role: tone === 'warn' ? 'alert' : 'status',
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -5,17 +5,7 @@ export function isInvoiceReady(c) {
|
||||
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
|
||||
}
|
||||
|
||||
export function customerStage(c) {
|
||||
const s = (c?.customerStatus || '').toLowerCase()
|
||||
if (s === 'lead') return 'lead'
|
||||
if (s === 'lost') return 'lost'
|
||||
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
|
||||
}
|
||||
|
||||
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
|
||||
export function isArchived(c) {
|
||||
return Boolean(c?.archived)
|
||||
}
|
||||
export { customerStage, isArchived, filterCustomers, stageCounts } from './customerList'
|
||||
|
||||
/** Kunde archivieren / wiederherstellen. */
|
||||
export async function setCustomerArchived(customer, archived) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Anzeige-Texte der Vertragsliste im Bereich Rechtliches (reine Funktionen, ohne React).
|
||||
// Die Rechenlogik der Fristen steht in legal.js; hier nur Beschriftung, Badge und Daempfung.
|
||||
import { deadlineTone, formatDate } from './legal'
|
||||
|
||||
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
|
||||
export function costLabel(c) {
|
||||
if (c.cost === null || c.cost === undefined) return '–'
|
||||
return `${euro.format(c.cost)}/${c.costInterval === 'year' ? 'Jahr' : 'Monat'}`
|
||||
}
|
||||
|
||||
export function termLabel(c) {
|
||||
if (c.termMonths === null || c.termMonths === undefined) return '–'
|
||||
if (c.termMonths === 0) return 'jederzeit kündbar'
|
||||
const renew = c.renewalMonths ? `, dann je ${c.renewalMonths} Monate` : ', endet danach'
|
||||
return `${c.termMonths} Monate${renew}`
|
||||
}
|
||||
|
||||
export function noticeLabel(c) {
|
||||
if (c.noticeValue === null || c.noticeValue === undefined) return '–'
|
||||
return `${c.noticeValue} ${c.noticeUnit === 'days' ? 'Tage' : 'Monate'}`
|
||||
}
|
||||
|
||||
export function deadlineLabel(r) {
|
||||
switch (r.state) {
|
||||
case 'active': return `kündbar bis ${formatDate(r.noticeBy)} (Periode endet ${formatDate(r.periodEnd)})`
|
||||
case 'anytime': return 'jederzeit kündbar'
|
||||
case 'ends': return `endet am ${formatDate(r.periodEnd)}`
|
||||
case 'ended': return `beendet seit ${formatDate(r.periodEnd)}`
|
||||
case 'cancelled': return r.periodEnd ? `gekündigt zum ${formatDate(r.periodEnd)}` : 'gekündigt'
|
||||
default: return 'Fristen fehlen'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Darstellung der Frist-Spalte: text (voller Satz), lead und rest (Aufteilung, wenn ein
|
||||
* Badge gesetzt ist, sonst lead = text und rest leer), badge ('danger' bei rot, 'warn' bei
|
||||
* orange, sonst null) und muted (Vertrag beendet, gekuendigt oder unvollstaendig).
|
||||
*/
|
||||
export function deadlineView(result, today) {
|
||||
const tone = deadlineTone(result, today)
|
||||
const text = deadlineLabel(result)
|
||||
const badge = tone === 'red' ? 'danger' : tone === 'orange' ? 'warn' : null
|
||||
const muted = tone === 'grey' || tone === 'missing'
|
||||
if (!badge) return { text, lead: text, rest: '', badge, muted }
|
||||
return {
|
||||
text,
|
||||
lead: `kündbar bis ${formatDate(result.noticeBy)}`,
|
||||
rest: `(Periode endet ${formatDate(result.periodEnd)})`,
|
||||
badge,
|
||||
muted,
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -1,4 +1,4 @@
|
||||
import { berlinYmd, fmtBerlin, fmtDate, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
|
||||
import { berlinYmd, fmtBerlin, fmtDate, fmtDateTime, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
|
||||
|
||||
// Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem frueheren Admin-Panel des Portals.
|
||||
// "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar.
|
||||
@@ -11,6 +11,24 @@ export const OFFER_STATUS = {
|
||||
}
|
||||
export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status }
|
||||
|
||||
/** Listenfilter: "all" zeigt alles, sonst nur offene und aktive Angebote; ohne Liste leer, die Eingabe bleibt unveraendert */
|
||||
export const filterOffers = (offers, filter) =>
|
||||
(offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
|
||||
|
||||
/** Aktionen der Zeile: Beschriftung des E-Mail-Knopfs ('' = kein Knopf) und ob Zurueckziehen moeglich ist */
|
||||
export const offerRowActions = (o) => ({
|
||||
sendLabel: o.mailSendable ? (o.notifiedAt ? 'Erneut senden' : 'E-Mail senden') : '',
|
||||
canWithdraw: ['offen', 'abgelaufen'].includes(o.status),
|
||||
})
|
||||
|
||||
/** Mail-Zeile unter dem Status (Reihenfolge: gesendet, nicht verschickt, ohne Adresse), sonst null */
|
||||
export function offerMailLine(o) {
|
||||
if (o.notifiedAt) return { tone: 'muted', text: `E-Mail gesendet ${fmtDateTime(o.notifiedAt)}` }
|
||||
if (o.mailSendable) return { tone: 'warn', text: 'E-Mail nicht verschickt' }
|
||||
if (o.status === 'offen' && !o.customerEmail) return { tone: 'warn', text: 'Keine E-Mail-Adresse hinterlegt' }
|
||||
return null
|
||||
}
|
||||
|
||||
const todayBerlin = () => berlinYmd(new Date())
|
||||
|
||||
export function offerPreviewText(price, months, regular, running) {
|
||||
|
||||
@@ -29,6 +29,22 @@ export function projectsInView(projects, filter, { isAdmin = false } = {}) {
|
||||
|
||||
export const VIEW_LABELS = { all: 'Aktive Projekte', templates: 'Vorlagen', archived: 'Archiviert', everything: 'Alle inkl. Archiv' }
|
||||
|
||||
/**
|
||||
* Reiter der Projektliste (Reihenfolge wie die bisherige Filter-Auswahl), je mit Zaehler aus
|
||||
* `projectCounts`. Die IDs sind die Filterwerte der Seite; die Vorlagen-Ansicht gibt es nur fuer Admins.
|
||||
*/
|
||||
export function projectTabs(counts, { isAdmin = false } = {}) {
|
||||
const tab = (id, label) => ({ id, label, count: counts?.[id] ?? 0 })
|
||||
return [
|
||||
tab('all', VIEW_LABELS.all),
|
||||
tab('unassigned', 'Unzugeordnet'),
|
||||
tab('assigned', 'Zugeordnet'),
|
||||
...(isAdmin ? [tab('templates', VIEW_LABELS.templates)] : []),
|
||||
tab('archived', VIEW_LABELS.archived),
|
||||
tab('everything', VIEW_LABELS.everything),
|
||||
]
|
||||
}
|
||||
|
||||
// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins
|
||||
const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything']
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Logik der Projektliste (Typ, Hosting-Zustand, Aktionen je Zeile) ohne Markup.
|
||||
// Aus ProjectsPage herausgezogen; die Töne (info/ok/warn/muted) lösen die Badge-Komponenten auf.
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { fmtDateTime } from './format'
|
||||
|
||||
// Projekt-Typen (= "Template"/Hosting-Modus). Die Reihenfolge der Schlüssel bestimmt die Optionen im Dialog.
|
||||
export const PROJECT_TYPES = {
|
||||
preview: {
|
||||
label: 'Preview (mit Login)',
|
||||
short: 'Preview',
|
||||
tone: 'info',
|
||||
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
|
||||
},
|
||||
website: {
|
||||
label: 'Oeffentliche Website',
|
||||
short: 'Website',
|
||||
tone: 'ok',
|
||||
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
|
||||
},
|
||||
template: {
|
||||
label: 'Vorlage',
|
||||
short: 'Vorlage',
|
||||
tone: 'warn',
|
||||
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
|
||||
adminOnly: true,
|
||||
},
|
||||
project: {
|
||||
label: 'Internes Projekt (kein Hosting)',
|
||||
short: 'Projekt',
|
||||
tone: 'muted',
|
||||
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
|
||||
},
|
||||
}
|
||||
|
||||
// Typen mit Hosting (Subdomain + Deploy)
|
||||
export const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
|
||||
|
||||
export function typeOf(project) {
|
||||
if (project?.isTemplate) return 'template'
|
||||
if (project?.isPublic) return 'website'
|
||||
if (project?.subdomain) return 'preview'
|
||||
return 'project'
|
||||
}
|
||||
|
||||
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
|
||||
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
|
||||
export function isProjectReady(project) {
|
||||
const st = String(project?.provisioningStatus || project?.status || '').toLowerCase()
|
||||
return ['ready', 'deployed'].includes(st)
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Aktionen und welches Hosting-Kennzeichen eine Zeile zeigt.
|
||||
* host = "Hosten" (noch nicht deployt), preview = Vorschau-Link, refresh = "Aktualisieren" (erneut deployen).
|
||||
*/
|
||||
export function projectActions(project) {
|
||||
const type = typeOf(project)
|
||||
const hosted = HOSTED_TYPES.has(type)
|
||||
const ready = isProjectReady(project)
|
||||
const archived = Boolean(project?.archived)
|
||||
const host = hosted && !ready && !archived
|
||||
const preview = !host && Boolean(project?.previewUrl)
|
||||
const refresh = hosted && ready && !archived
|
||||
const gitea = Boolean(project?.giteaRepoUrl)
|
||||
const hostedBadge = !hosted || archived
|
||||
? null
|
||||
: ready
|
||||
? { text: 'gehostet', tone: 'ok' }
|
||||
: { text: 'nicht deployt', tone: 'warn' }
|
||||
return { type, hosted, ready, host, preview, refresh, gitea, hostedBadge }
|
||||
}
|
||||
|
||||
/** Ticketnummer (woid) der Zeile: aus der Ticketliste, sonst die $id, ohne Ticket leer. */
|
||||
export function projectTicketRef(project, ticketMap) {
|
||||
const id = project?.ticketId
|
||||
if (!id) return ''
|
||||
return String(ticketMap?.[id] ?? id)
|
||||
}
|
||||
|
||||
/** "vor etwa 3 Stunden" mit Titel in Berliner Zeit; fehlend oder unlesbar -> "-". */
|
||||
export function updatedLabel(iso, now = new Date()) {
|
||||
if (!iso) return { text: '-', title: '' }
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return { text: '-', title: '' }
|
||||
return {
|
||||
text: formatDistance(date, now, { addSuffix: true, locale: de }),
|
||||
title: fmtDateTime(iso),
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,23 @@ export function formatNumber(v, digits = 2) {
|
||||
return nf(digits).format(v)
|
||||
}
|
||||
|
||||
// Richtung eines Kachel-Deltas: unter 1e-9 gilt als unverändert (flat).
|
||||
export function deltaDir(d) {
|
||||
return Math.abs(d) < 1e-9 ? 'flat' : d > 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
// Vorzeichen zur Richtung: '+', Minuszeichen U+2212, bei flat nichts.
|
||||
export function deltaSign(dir) {
|
||||
return dir === 'up' ? '+' : dir === 'down' ? '−' : ''
|
||||
}
|
||||
|
||||
// Badge-Ton je Warnstufe; liest nur das erste Argument (.map(levelTone) reicht Index und Array mit).
|
||||
const LEVEL_TONES = { ok: 'ok', medium: 'warn', critical: 'danger' }
|
||||
|
||||
export function levelTone(level) {
|
||||
return Object.prototype.hasOwnProperty.call(LEVEL_TONES, level) ? LEVEL_TONES[level] : 'muted'
|
||||
}
|
||||
|
||||
// --- Zeitachsen (Europe/Berlin, de-DE) ---
|
||||
const TZ = 'Europe/Berlin'
|
||||
const dtf = (opts) => new Intl.DateTimeFormat('de-DE', { timeZone: TZ, ...opts })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fmtDate } from './format'
|
||||
import { fmtDate, fmtDateTime } from './format'
|
||||
|
||||
// E-Mail-Speicher (Zusatzspeicher-Aufgaben in konsoleH): Texte wortgleich aus dem frueheren Admin-Panel des Portals.
|
||||
|
||||
@@ -35,5 +35,11 @@ export const orderedKind = (t) => (t.toUnits > t.fromUnits ? 'Bestellung (Plus)'
|
||||
|
||||
export const isOverdue = (t, now = Date.now()) => Boolean(t.dueAt && new Date(t.dueAt).getTime() <= now)
|
||||
|
||||
/** Faelligkeitsspalte: Einrichten sofort, Verringern zum Periodenende (ueberfaellig = warn); sub in gedaempft, wenn subMuted */
|
||||
export function storageDue(t, now = Date.now()) {
|
||||
if (t.kind === 'einrichten') return { tone: 'warn', label: 'jetzt einrichten', sub: `bestellt ${fmtDateTime(t.createdAt)}`, subMuted: true }
|
||||
return { tone: isOverdue(t, now) ? 'warn' : 'muted', label: 'zum Periodenende', sub: fmtDate(t.dueAt), subMuted: false }
|
||||
}
|
||||
|
||||
export const failedText = (f) =>
|
||||
`${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}`
|
||||
+18
-416
@@ -1,418 +1,20 @@
|
||||
/* .page (global.css, wird danach geladen) gewinnt bei gleicher Spezifitaet: darum .page.admin-page */
|
||||
.page.admin-page {
|
||||
max-width: 1480px;
|
||||
}
|
||||
.admin-page {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
/* Admin: Mitarbeiter-Tabelle, Zugangsdaten, Dialog. Karten, Rahmen und Schatten gibt es nicht mehr; Abstände zwischen den */
|
||||
/* Abschnitten liefert .page section + section (global.css). AdminPage.css lädt vor global.css, darum haben Regeln, die eine */
|
||||
/* Global-Klasse überstimmen, die Spezifität 0,2,0. Zugangsdaten (.credential*) und .sr-only stehen in global.css. */
|
||||
.page.admin-page { max-width: 1480px; }
|
||||
.admin-notice { margin-bottom: 16px; }
|
||||
.admin-lead { margin: 0 0 8px; }
|
||||
.admin-shortcode { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; letter-spacing: .04em; }
|
||||
.admin-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; }
|
||||
.credential-list { display: flex; flex-wrap: wrap; gap: 8px 32px; margin-top: 8px; }
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
/* Admin-Haken im Dialog: Titel und Erklärung untereinander, der Haken oben links */
|
||||
.check.admin-check { align-items: flex-start; }
|
||||
.admin-check small { display: block; margin-top: 2px; font-size: 13px; line-height: 18px; color: var(--text-2); }
|
||||
.admin-check.is-disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 4px 0 6px;
|
||||
font-size: clamp(28px, 3vw, 38px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.admin-header p,
|
||||
.admin-current-user span,
|
||||
.admin-section-head span {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-current-user {
|
||||
min-width: 220px;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.admin-current-user span,
|
||||
.admin-current-user strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-current-user strong {
|
||||
margin-top: 3px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 22px;
|
||||
border-bottom: 1px solid var(--admin-line);
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
position: relative;
|
||||
padding: 12px 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-tabs button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: -1px;
|
||||
left: 18px;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-tabs button:hover,
|
||||
.admin-tabs button.active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-tabs button.active::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.admin-notice {
|
||||
margin-bottom: 16px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--admin-line);
|
||||
border-left-width: 3px;
|
||||
background: var(--surface-1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-notice.success { border-left-color: var(--ok); }
|
||||
.admin-notice.error { border-left-color: var(--danger); }
|
||||
|
||||
.admin-form-card,
|
||||
.admin-list-card {
|
||||
border: 1px solid var(--admin-line);
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.admin-section-head {
|
||||
display: flex;
|
||||
min-height: 62px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--admin-line);
|
||||
}
|
||||
|
||||
.admin-section-head h2,
|
||||
.admin-section-head h3 {
|
||||
margin: 0 0 3px;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.admin-icon-button {
|
||||
display: inline-flex;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--admin-line-strong);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-icon-button:hover {
|
||||
border-color: var(--accent-ring);
|
||||
color: var(--text);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.admin-icon-button.danger:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--red-fg);
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.admin-primary-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 15px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-primary-button:hover { background: var(--accent-hover); }
|
||||
.admin-primary-button:disabled { opacity: 0.55; cursor: wait; }
|
||||
|
||||
.admin-split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(290px, 360px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-form-card {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-form-card > .admin-field,
|
||||
.admin-form-card > .admin-checkbox,
|
||||
.admin-form-card > .admin-primary-button {
|
||||
margin-right: 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.admin-field {
|
||||
display: block;
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.admin-field > span {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.admin-field em {
|
||||
color: var(--text-faint);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-form-card > .admin-primary-button {
|
||||
width: calc(100% - 32px);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--admin-line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-checkbox input { margin-top: 2px; accent-color: var(--accent); }
|
||||
.admin-checkbox span,
|
||||
.admin-checkbox strong,
|
||||
.admin-checkbox small { display: block; }
|
||||
.admin-checkbox strong { font-size: 13px; }
|
||||
.admin-checkbox small { margin-top: 3px; color: var(--text-muted); line-height: 1.35; }
|
||||
.admin-checkbox.disabled { cursor: not-allowed; opacity: 0.65; }
|
||||
|
||||
.admin-list-card { min-width: 0; }
|
||||
.admin-table-wrap { width: 100%; overflow-x: auto; }
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
padding: 10px 13px;
|
||||
border-bottom: 1px solid var(--admin-line);
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.admin-table tbody tr:hover td { background: var(--bg-subtle); }
|
||||
.admin-table td > strong,
|
||||
.admin-table td > span { display: block; }
|
||||
.admin-table td > span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.admin-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Tabellenzellen: .admin-table td > span (0,1,2) setzt display/Farbe/Schrift; die Selektoren hier sind spezifischer (0,2,1) */
|
||||
.admin-table td > .admin-status,
|
||||
.admin-table td > .admin-role {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--admin-line-strong);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-table td > .admin-status.active,
|
||||
.admin-table td > .admin-role.admin {
|
||||
border-color: var(--green-fg);
|
||||
color: var(--green-fg);
|
||||
}
|
||||
|
||||
.admin-table td > .admin-shortcode {
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.credential {
|
||||
display: flex;
|
||||
max-width: 300px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.credential-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.credential-value.is-masked {
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.credential-action {
|
||||
display: inline-flex;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
flex: 0 0 25px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.credential-action:hover { background: var(--surface-2); color: var(--text); }
|
||||
.credential-check { color: var(--ok); }
|
||||
.credential-empty { color: var(--text-faint); }
|
||||
|
||||
.admin-credential-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1fr) minmax(180px, auto) minmax(180px, auto) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.admin-credential-result strong,
|
||||
.admin-credential-result span { display: block; }
|
||||
.admin-credential-result span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.admin-loading,
|
||||
.admin-empty-state,
|
||||
.admin-empty-line {
|
||||
padding: 28px 18px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-loading .spinner { width: 22px; height: 22px; margin-bottom: 9px; }
|
||||
.admin-empty-state h3 { margin-bottom: 5px; color: var(--text); font-size: 15px; }
|
||||
.admin-empty-state p { font-size: 13px; }
|
||||
.admin-empty-line { padding: 16px 8px; font-size: 13px; }
|
||||
|
||||
.admin-access-denied {
|
||||
max-width: 540px;
|
||||
margin: 12vh auto 0;
|
||||
padding: 32px;
|
||||
border-left: 3px solid var(--danger);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.admin-access-denied > span { color: var(--danger); font-size: 12px; font-weight: 800; letter-spacing: 0.1em; }
|
||||
.admin-access-denied h1 { margin: 8px 0; }
|
||||
.admin-access-denied p { color: var(--text-muted); }
|
||||
|
||||
.spin { animation: admin-spin 0.8s linear infinite; }
|
||||
@keyframes admin-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1060px) {
|
||||
.admin-split-layout { grid-template-columns: 1fr; }
|
||||
.admin-form-card { position: static; }
|
||||
.admin-credential-result { grid-template-columns: 1fr auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-page { padding: 16px; }
|
||||
.admin-header { align-items: flex-start; flex-direction: column; }
|
||||
.admin-current-user { width: 100%; }
|
||||
.admin-tabs { overflow-x: auto; }
|
||||
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
|
||||
.admin-credential-result { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* 403 */
|
||||
.admin-access-denied { max-width: 540px; margin: 12vh auto 0; }
|
||||
.admin-access-denied > span { font-size: 12px; font-weight: 500; color: var(--red-fg); }
|
||||
.admin-access-denied h1 { margin: 4px 0 8px; font-size: 28px; line-height: 34px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.admin-access-denied p { color: var(--text-2); }
|
||||
+192
-134
@@ -1,15 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FaFloppyDisk,
|
||||
FaPlus,
|
||||
FaSpinner,
|
||||
FaTrash,
|
||||
FaXmark,
|
||||
} from 'react-icons/fa6'
|
||||
import { FaEdit } from 'react-icons/fa'
|
||||
import { useRef, useState } from 'react'
|
||||
import CopyableCredential from '../components/CopyableCredential'
|
||||
import { Badge, Button, Card, Dialog, EmptyState, Field, PageHeader, Tabs } from '../components/ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||||
import { validateEmployeeForm } from '../lib/employeeForm'
|
||||
import './AdminPage.css'
|
||||
|
||||
const EMPTY_EMPLOYEE = {
|
||||
@@ -22,7 +17,11 @@ const EMPTY_EMPLOYEE = {
|
||||
|
||||
function Notice({ notice }) {
|
||||
if (!notice?.text) return null
|
||||
return <div className={`admin-notice ${notice.type}`}>{notice.text}</div>
|
||||
return (
|
||||
<div className={`msg ${notice.type === 'error' ? 'err' : 'ok'} admin-notice`} role={notice.type === 'error' ? 'alert' : 'status'}>
|
||||
{notice.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
@@ -40,9 +39,14 @@ export default function AdminPage() {
|
||||
const [activeSection, setActiveSection] = useState('employees')
|
||||
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
|
||||
const [editingEmployee, setEditingEmployee] = useState(null)
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [formError, setFormError] = useState(null)
|
||||
const [employeeBusy, setEmployeeBusy] = useState(false)
|
||||
const [createdCredential, setCreatedCredential] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const errorRef = useRef(null)
|
||||
// Hooks stehen vor dem frühen return bei !isAdmin
|
||||
useScrollIntoView(errorRef, formError)
|
||||
|
||||
const showNotice = (type, text) => {
|
||||
setNotice({ type, text })
|
||||
@@ -61,43 +65,21 @@ export default function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const resetEmployeeForm = () => {
|
||||
// Der Admin-Haken des eigenen Kontos ist gesperrt: man nimmt sich nicht selbst die Rechte
|
||||
const editingSelf = Boolean(editingEmployee) && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false)
|
||||
setFormError(null)
|
||||
setEmployeeForm(EMPTY_EMPLOYEE)
|
||||
setEditingEmployee(null)
|
||||
}
|
||||
|
||||
const submitEmployee = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!employeeForm.displayName.trim() || !employeeForm.email.trim()) {
|
||||
showNotice('error', 'Name und E-Mail sind erforderlich.')
|
||||
return
|
||||
}
|
||||
if (!editingEmployee && employeeForm.password.length < 8) {
|
||||
showNotice('error', 'Das Passwort muss mindestens 8 Zeichen haben.')
|
||||
return
|
||||
}
|
||||
if (editingEmployee && employeeForm.password && employeeForm.password.length < 8) {
|
||||
showNotice('error', 'Ein neues Passwort muss mindestens 8 Zeichen haben.')
|
||||
return
|
||||
}
|
||||
|
||||
setEmployeeBusy(true)
|
||||
const result = editingEmployee
|
||||
? await updateEmployee(editingEmployee, employeeForm)
|
||||
: await createEmployee(employeeForm)
|
||||
setEmployeeBusy(false)
|
||||
if (!result.success) {
|
||||
showNotice('error', result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
|
||||
return
|
||||
}
|
||||
if (!editingEmployee) {
|
||||
setCreatedCredential({
|
||||
email: employeeForm.email.trim(),
|
||||
password: employeeForm.password,
|
||||
})
|
||||
}
|
||||
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
|
||||
resetEmployeeForm()
|
||||
const createNew = () => {
|
||||
setEmployeeForm(EMPTY_EMPLOYEE)
|
||||
setEditingEmployee(null)
|
||||
setFormError(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const editEmployee = (employee) => {
|
||||
@@ -109,6 +91,36 @@ export default function AdminPage() {
|
||||
shortcode: employee.shortcode || '',
|
||||
isAdmin: Boolean(employee.isAdmin),
|
||||
})
|
||||
setFormError(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const submitEmployee = async (event) => {
|
||||
event.preventDefault()
|
||||
const message = validateEmployeeForm(employeeForm, Boolean(editingEmployee))
|
||||
if (message) {
|
||||
setFormError(message)
|
||||
return
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
setEmployeeBusy(true)
|
||||
const result = editingEmployee
|
||||
? await updateEmployee(editingEmployee, employeeForm)
|
||||
: await createEmployee(employeeForm)
|
||||
setEmployeeBusy(false)
|
||||
if (!result.success) {
|
||||
setFormError(result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
|
||||
return
|
||||
}
|
||||
if (!editingEmployee) {
|
||||
setCreatedCredential({
|
||||
email: employeeForm.email.trim(),
|
||||
password: employeeForm.password,
|
||||
})
|
||||
}
|
||||
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
|
||||
closeForm()
|
||||
}
|
||||
|
||||
const removeEmployee = async (employee) => {
|
||||
@@ -120,107 +132,153 @@ export default function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const setField = (key) => (event) => setEmployeeForm({ ...employeeForm, [key]: event.target.value })
|
||||
|
||||
return (
|
||||
<div className="page admin-page">
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<span className="admin-eyebrow">Systemverwaltung</span>
|
||||
<h1>Admin</h1>
|
||||
<p>Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“.</p>
|
||||
</div>
|
||||
<div className="admin-current-user">
|
||||
<span>Angemeldet als</span>
|
||||
<strong>{user?.name || user?.email}</strong>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
eyebrow="Systemverwaltung"
|
||||
title="Admin"
|
||||
subtitle="Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“."
|
||||
actions={
|
||||
<>
|
||||
<span className="muted small">Angemeldet als <strong>{user?.name || user?.email}</strong></span>
|
||||
<Button variant="primary" type="button" onClick={createNew}>Mitarbeiter anlegen</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Notice notice={notice} />
|
||||
|
||||
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||
{[
|
||||
['employees', `Mitarbeiter (${employees.length})`],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={activeSection === id ? 'active' : ''}
|
||||
onClick={() => setActiveSection(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<Tabs
|
||||
tabs={[{ id: 'employees', label: `Mitarbeiter (${employees.length})` }]}
|
||||
active={activeSection}
|
||||
onChange={setActiveSection}
|
||||
/>
|
||||
|
||||
{activeSection === 'employees' && (
|
||||
<section>
|
||||
<>
|
||||
{createdCredential && (
|
||||
<div className="admin-credential-result">
|
||||
<div><strong>Login wurde angelegt</strong><span>Diese Zugangsdaten jetzt sicher weitergeben.</span></div>
|
||||
<CopyableCredential value={createdCredential.email} label="E-Mail" />
|
||||
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
|
||||
<button type="button" className="admin-icon-button" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen"><FaXmark /></button>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-split-layout">
|
||||
<form className="admin-form-card" onSubmit={submitEmployee}>
|
||||
<div className="admin-section-head">
|
||||
<div>
|
||||
<h2>{editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}</h2>
|
||||
<span>Die User-ID wird automatisch vergeben.</span>
|
||||
</div>
|
||||
{editingEmployee && <button type="button" className="admin-icon-button" onClick={resetEmployeeForm} aria-label="Bearbeitung schließen"><FaXmark /></button>}
|
||||
<Card
|
||||
title="Login wurde angelegt"
|
||||
actions={
|
||||
<button type="button" className="btn btn-quiet" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen">×</button>
|
||||
}
|
||||
>
|
||||
<p className="muted small">Diese Zugangsdaten jetzt sicher weitergeben.</p>
|
||||
<div className="credential-list">
|
||||
<CopyableCredential value={createdCredential.email} label="E-Mail" />
|
||||
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
|
||||
</div>
|
||||
<label className="admin-field"><span>Name</span><input className="form-control" required value={employeeForm.displayName} onChange={(event) => setEmployeeForm({ ...employeeForm, displayName: event.target.value })} /></label>
|
||||
<label className="admin-field"><span>E-Mail</span><input type="email" className="form-control" required value={employeeForm.email} onChange={(event) => setEmployeeForm({ ...employeeForm, email: event.target.value })} /></label>
|
||||
<label className="admin-field"><span>{editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'}</span><input type="password" className="form-control" autoComplete="new-password" required={!editingEmployee} value={employeeForm.password} onChange={(event) => setEmployeeForm({ ...employeeForm, password: event.target.value })} placeholder="Mindestens 8 Zeichen" /></label>
|
||||
<label className="admin-field"><span>Kürzel</span><input className="form-control" maxLength={10} value={employeeForm.shortcode} onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })} placeholder="z. B. KNSO" /></label>
|
||||
<label className={`admin-checkbox ${editingEmployee && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id ? 'disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={employeeForm.isAdmin}
|
||||
disabled={editingEmployee && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id}
|
||||
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
|
||||
/>
|
||||
<span><strong>Admin-Account</strong><small>Darf Systemkonfiguration und Benutzer verwalten.</small></span>
|
||||
</label>
|
||||
<button className="admin-primary-button" disabled={employeeBusy}>
|
||||
{employeeBusy ? <FaSpinner className="spin" /> : editingEmployee ? <FaFloppyDisk /> : <FaPlus />}
|
||||
{editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="admin-list-card">
|
||||
<div className="admin-section-head"><div><h2>Mitarbeiterkonten</h2><span>Aktive Logins für das Ticketsystem</span></div></div>
|
||||
{employeesError && <Notice notice={{ type: 'error', text: employeesError }} />}
|
||||
{employeesLoading ? (
|
||||
<div className="admin-loading"><FaSpinner className="spinner" /> Mitarbeiter werden geladen …</div>
|
||||
) : employees.length === 0 ? (
|
||||
<div className="admin-empty-state"><h3>Noch keine Mitarbeiter</h3><p>Lege links den ersten Login an.</p></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>Mitarbeiter</th><th>E-Mail</th><th>Kürzel</th><th>Rolle</th><th>User-ID</th><th><span className="sr-only">Aktionen</span></th></tr></thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => (
|
||||
<tr key={employee.$id}>
|
||||
<td><strong>{employee.displayName || '–'}</strong></td>
|
||||
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
|
||||
<td><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
|
||||
<td><span className={`admin-role ${employee.isAdmin ? 'admin' : ''}`}>{employee.isAdmin ? 'Admin' : 'Mitarbeiter'}</span></td>
|
||||
<td><CopyableCredential value={employee.userId} label="User-ID" /></td>
|
||||
<td className="admin-row-actions">
|
||||
<button type="button" className="admin-icon-button" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}><FaEdit /></button>
|
||||
{employee.userId !== user?.$id && <button type="button" className="admin-icon-button danger" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}><FaTrash /></button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<Card title="Mitarbeiterkonten">
|
||||
<p className="muted small admin-lead">Aktive Logins für das Ticketsystem</p>
|
||||
{employeesError && <div className="msg err" role="alert">{employeesError}</div>}
|
||||
{employeesLoading ? (
|
||||
<div className="loading">Mitarbeiter werden geladen …</div>
|
||||
) : employees.length === 0 ? (
|
||||
<EmptyState title="Noch keine Mitarbeiter" hint="Lege mit „Mitarbeiter anlegen“ den ersten Login an." />
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th>E-Mail</th>
|
||||
<th className="hide-sm">Kürzel</th>
|
||||
<th className="hide-sm">Rolle</th>
|
||||
<th className="hide-sm">User-ID</th>
|
||||
<th className="right"><span className="sr-only">Aktionen</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => (
|
||||
<tr key={employee.$id}>
|
||||
<td><strong>{employee.displayName || '–'}</strong></td>
|
||||
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
|
||||
<td className="hide-sm"><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
|
||||
<td className="hide-sm">
|
||||
{employee.isAdmin ? <Badge tone="ok">Admin</Badge> : <span className="muted">Mitarbeiter</span>}
|
||||
</td>
|
||||
<td className="hide-sm"><CopyableCredential value={employee.userId} label="User-ID" /></td>
|
||||
<td className="right">
|
||||
<div className="admin-actions">
|
||||
<button type="button" className="link" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}>Bearbeiten</button>
|
||||
{employee.userId !== user?.$id && (
|
||||
<Button variant="danger" type="button" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}>Löschen</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<Dialog
|
||||
as="form"
|
||||
title={editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}
|
||||
busy={employeeBusy}
|
||||
onClose={closeForm}
|
||||
onSubmit={submitEmployee}
|
||||
footer={
|
||||
<>
|
||||
<span className="grow" />
|
||||
<Button type="button" disabled={employeeBusy} onClick={closeForm}>Abbrechen</Button>
|
||||
<Button variant="primary" type="submit" disabled={employeeBusy}>
|
||||
{employeeBusy ? 'Speichert …' : editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{formError && <div className="msg err" role="alert" ref={errorRef}>{formError}</div>}
|
||||
<p className="muted small">Die User-ID wird automatisch vergeben.</p>
|
||||
<Field label="Name" htmlFor="employee-displayName">
|
||||
<input className="input" id="employee-displayName" required value={employeeForm.displayName} onChange={setField('displayName')} />
|
||||
</Field>
|
||||
<Field label="E-Mail" htmlFor="employee-email">
|
||||
<input className="input" id="employee-email" type="email" required value={employeeForm.email} onChange={setField('email')} />
|
||||
</Field>
|
||||
<Field label={editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'} htmlFor="employee-password">
|
||||
<input
|
||||
className="input"
|
||||
id="employee-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required={!editingEmployee}
|
||||
value={employeeForm.password}
|
||||
onChange={setField('password')}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kürzel" htmlFor="employee-shortcode">
|
||||
<input
|
||||
className="input"
|
||||
id="employee-shortcode"
|
||||
maxLength={10}
|
||||
value={employeeForm.shortcode}
|
||||
onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })}
|
||||
placeholder="z. B. KNSO"
|
||||
/>
|
||||
</Field>
|
||||
<label className={`check admin-check${editingSelf ? ' is-disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={employeeForm.isAdmin}
|
||||
disabled={editingSelf}
|
||||
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
<strong>Admin-Account</strong>
|
||||
<small>Darf Systemkonfiguration und Benutzer verwalten.</small>
|
||||
</span>
|
||||
</label>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+181
-163
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FaSpinner, FaPlus, FaArrowLeft, FaArrowRight, FaCheck } from 'react-icons/fa6'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
@@ -9,7 +8,7 @@ import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from
|
||||
import { suggestSubdomain, slugify } from '../lib/subdomain'
|
||||
import { isArchived } from '../lib/leads'
|
||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||
import { PageHeader, Card, Button, Field } from '../components/ui'
|
||||
import { PageHeader, Card, Button, Field, Badge } from '../components/ui'
|
||||
import { CreateCustomerForm } from './CustomersPage'
|
||||
|
||||
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
@@ -176,36 +175,45 @@ export default function AssignProjectPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
|
||||
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Projekt zuordnen" />
|
||||
<Card><div className="text-red">{loadError}</div></Card>
|
||||
<PageHeader crumb={[{ label: 'Projekte', to: '/projects' }]} title="Projekt zuordnen" />
|
||||
<p className="msg err" role="alert">{loadError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const createdLine = project.$createdAt && (
|
||||
<> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>
|
||||
)
|
||||
const header = (
|
||||
<Card>
|
||||
<div style={{ fontWeight: 700, fontSize: 18 }}>{repoName || project.projectName}</div>
|
||||
<div className="muted" style={{ marginTop: 4 }}>
|
||||
{repoUrl && <a href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
|
||||
{project.$createdAt && <> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>}
|
||||
</div>
|
||||
</Card>
|
||||
<PageHeader
|
||||
crumb={[{ label: 'Projekte', to: '/projects' }, { label: repoName || project.projectName }]}
|
||||
title="Projekt zuordnen"
|
||||
subtitle={(repoUrl || createdLine) ? (
|
||||
<>
|
||||
{repoUrl && <a className="link" href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
|
||||
{createdLine}
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Projekt zuordnen" />
|
||||
{header}
|
||||
<Card title="Zugeordnet, mit Hinweisen">
|
||||
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
|
||||
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
<Button variant="primary" onClick={() => navigate(done.path)}>Weiter <FaArrowRight /></Button>
|
||||
<div className="assign-step">
|
||||
<ul className="summary">
|
||||
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
<div className="row-buttons">
|
||||
<Button type="button" variant="primary" onClick={() => navigate(done.path)}>Weiter</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -215,16 +223,15 @@ export default function AssignProjectPage() {
|
||||
if (project.kind) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Projekt zuordnen" />
|
||||
{header}
|
||||
<Card>
|
||||
{notice && <div className="text-red" style={{ marginBottom: 8 }}>{notice}</div>}
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
|
||||
</p>
|
||||
{project.ticketId && (
|
||||
<p style={{ marginBottom: 0 }}><TicketLink ticketId={project.ticketId} /></p>
|
||||
)}
|
||||
<div className="assign-step">
|
||||
{notice && <p className="msg err" role="alert">{notice}</p>}
|
||||
<p>
|
||||
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
|
||||
</p>
|
||||
{project.ticketId && <p><TicketLink ticketId={project.ticketId} /></p>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -236,45 +243,44 @@ export default function AssignProjectPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Projekt zuordnen" subtitle={STEP_TITLES[state.step]} />
|
||||
{header}
|
||||
|
||||
<Card title={STEP_TITLES[state.step]}>
|
||||
{state.step === 'kind' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 }}>
|
||||
{kinds.map((k) => (
|
||||
<Button
|
||||
key={k.key}
|
||||
variant={state.kind === k.key ? 'primary' : 'default'}
|
||||
style={{ padding: '18px 12px', fontSize: 16, fontWeight: 700 }}
|
||||
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
|
||||
>
|
||||
{k.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.step === 'customer?' && (
|
||||
<div className="flex gap-2 wrap">
|
||||
<Button variant="primary" style={{ padding: '14px 28px' }}
|
||||
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
|
||||
<Button style={{ padding: '14px 28px' }}
|
||||
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.step === 'customer' && (
|
||||
<>
|
||||
<div className="flex gap-2 wrap" style={{ marginBottom: 10 }}>
|
||||
<input className="form-control search-input" placeholder="Kunde suchen …" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
|
||||
{isAdmin && (
|
||||
<Button onClick={() => setShowNewCustomer((s) => !s)}><FaPlus /> Neuer Kunde</Button>
|
||||
)}
|
||||
<div className="assign-step">
|
||||
{state.step === 'kind' && (
|
||||
<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
key={k.key}
|
||||
type="button"
|
||||
aria-pressed={state.kind === k.key}
|
||||
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showNewCustomer && isAdmin && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
)}
|
||||
|
||||
{state.step === 'customer?' && (
|
||||
<div className="row-buttons">
|
||||
<Button type="button" variant="primary"
|
||||
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
|
||||
<Button type="button"
|
||||
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.step === 'customer' && (
|
||||
<>
|
||||
<div className="row-buttons">
|
||||
<input className="input search" aria-label="Kunde suchen" placeholder="Kunde suchen …" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)} />
|
||||
{isAdmin && (
|
||||
<Button type="button" onClick={() => setShowNewCustomer((s) => !s)}>Neuer Kunde</Button>
|
||||
)}
|
||||
</div>
|
||||
{showNewCustomer && isAdmin && (
|
||||
<CreateCustomerForm
|
||||
onCancel={() => setShowNewCustomer(false)}
|
||||
onCreate={async (data) => {
|
||||
@@ -286,111 +292,123 @@ export default function AssignProjectPage() {
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="list" style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{filteredCustomers.slice(0, 100).map((c) => (
|
||||
<div key={c.$id} className="list-row" style={{ cursor: 'pointer', background: state.customerId === c.$id ? 'var(--surface-2)' : undefined }}
|
||||
onClick={() => patch({ customerId: c.$id, ticketId: '', newTicket: null })}>
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 700 }}>{c.name}{c.code && <span className="faint" style={{ fontWeight: 400 }}> · {c.code}</span>}</div>
|
||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
|
||||
</div>
|
||||
{state.customerId === c.$id && <FaCheck />}
|
||||
</div>
|
||||
))}
|
||||
{filteredCustomers.length === 0 && <div className="muted" style={{ padding: 12 }}>Kein Kunde gefunden.</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'ticket' && (
|
||||
<>
|
||||
{ticketsLoading ? <FaSpinner className="spinner" /> : ticketsError ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div className="text-red" style={{ marginBottom: 8 }}>Die Tickets des Kunden konnten nicht geladen werden.</div>
|
||||
<Button onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="list" style={{ marginBottom: 12 }}>
|
||||
{openTickets.map((t) => (
|
||||
<div key={t.$id} className="list-row" style={{ cursor: 'pointer', background: state.ticketId === t.$id ? 'var(--surface-2)' : undefined }}
|
||||
onClick={() => patch({ ticketId: t.$id, newTicket: null })}>
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 700 }}>#{t.woid} {t.topic}</div>
|
||||
<div className="muted">{[t.type, t.status].filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
{state.ticketId === t.$id && <FaCheck />}
|
||||
</div>
|
||||
))}
|
||||
{openTickets.length === 0 && <div className="muted" style={{ padding: 12 }}>Dieser Kunde hat keine offenen Tickets.</div>}
|
||||
</div>
|
||||
)}
|
||||
{!ticketsError && !ticketsLoading && (
|
||||
<Button variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}><FaPlus /> Neues Ticket anlegen</Button>
|
||||
)}
|
||||
{state.newTicket && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Thema"><input className="form-control" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
|
||||
<Field label="Angefragt von"><input className="form-control" value={state.newTicket.requestedBy} onChange={setNT('requestedBy')} /></Field>
|
||||
<Field label="Start"><input className="form-control" type="date" value={state.newTicket.startDate} onChange={setNT('startDate')} /></Field>
|
||||
</div>
|
||||
<Field label="Details"><textarea className="form-control" rows={3} value={state.newTicket.details} onChange={setNT('details')} /></Field>
|
||||
<div className="muted" style={{ fontSize: 13 }}>Ticket-Art: {kindObj?.ticketType === 'Migration' ? 'Migration' : 'Webpage'} (ergibt sich aus der Art)</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'summary' && (
|
||||
<>
|
||||
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
|
||||
<li>Art: <strong>{kindObj?.label}</strong></li>
|
||||
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
|
||||
{state.forCustomer && (
|
||||
<li>
|
||||
{state.ticketId
|
||||
? <>Ticket: <strong>{openTickets.find((t) => t.$id === state.ticketId)?.topic || 'vorhandenes Ticket'}</strong></>
|
||||
: <>Neues Ticket: <strong>{state.newTicket?.topic}</strong></>}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{isWebsiteWithCustomer && hasPreviewAlready && (
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
|
||||
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
|
||||
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
|
||||
</p>
|
||||
)}
|
||||
{isWebsiteWithCustomer && !hasPreviewAlready && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label className="flex gap-2" style={{ alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
||||
Preview einschalten
|
||||
</label>
|
||||
{previewOn && (
|
||||
<Field label="Subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
|
||||
<input className="form-control" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<div className="pick-list">
|
||||
{filteredCustomers.slice(0, 100).map((c) => {
|
||||
const picked = state.customerId === c.$id
|
||||
return (
|
||||
<button key={c.$id} type="button" className="list-row" aria-pressed={picked}
|
||||
onClick={() => patch({ customerId: c.$id, ticketId: '', newTicket: null })}>
|
||||
<span className="list-row-text">
|
||||
<span className="cell-title">{c.name}{c.code && <span className="faint"> · {c.code}</span>}</span>
|
||||
<span className="cell-sub">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</span>
|
||||
</span>
|
||||
{picked && <Badge>Ausgewählt</Badge>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredCustomers.length === 0 && <p className="muted pick-empty">Kein Kunde gefunden.</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'ticket' && (
|
||||
<>
|
||||
{ticketsLoading ? <p className="muted">Wird geladen …</p> : ticketsError ? (
|
||||
<>
|
||||
<p className="msg err" role="alert">Die Tickets des Kunden konnten nicht geladen werden.</p>
|
||||
<div className="row-buttons">
|
||||
<Button type="button" onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="pick-list">
|
||||
{openTickets.map((t) => {
|
||||
const picked = state.ticketId === t.$id
|
||||
return (
|
||||
<button key={t.$id} type="button" className="list-row" aria-pressed={picked}
|
||||
onClick={() => patch({ ticketId: t.$id, newTicket: null })}>
|
||||
<span className="list-row-text">
|
||||
<span className="cell-title">#{t.woid} {t.topic}</span>
|
||||
<span className="cell-sub">{[t.type, t.status].filter(Boolean).join(' · ')}</span>
|
||||
</span>
|
||||
{picked && <Badge>Ausgewählt</Badge>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{openTickets.length === 0 && <p className="muted pick-empty">Dieser Kunde hat keine offenen Tickets.</p>}
|
||||
</div>
|
||||
)}
|
||||
{!ticketsError && !ticketsLoading && (
|
||||
<div className="row-buttons">
|
||||
<Button type="button" variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}>Neues Ticket anlegen</Button>
|
||||
</div>
|
||||
)}
|
||||
{state.newTicket && (
|
||||
<>
|
||||
<div className="field-row-3">
|
||||
<Field label="Thema" htmlFor="assign-topic"><input id="assign-topic" className="input" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
|
||||
<Field label="Angefragt von" htmlFor="assign-requested-by"><input id="assign-requested-by" className="input" value={state.newTicket.requestedBy} onChange={setNT('requestedBy')} /></Field>
|
||||
<Field label="Start" htmlFor="assign-start"><input id="assign-start" className="input" type="date" value={state.newTicket.startDate} onChange={setNT('startDate')} /></Field>
|
||||
</div>
|
||||
<Field label="Details" htmlFor="assign-details"><textarea id="assign-details" className="textarea" rows={3} value={state.newTicket.details} onChange={setNT('details')} /></Field>
|
||||
<p className="muted small">Ticket-Art: {kindObj?.ticketType === 'Migration' ? 'Migration' : 'Webpage'} (ergibt sich aus der Art)</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'summary' && (
|
||||
<>
|
||||
<ul className="summary">
|
||||
<li>Art: <strong>{kindObj?.label}</strong></li>
|
||||
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
|
||||
{state.forCustomer && (
|
||||
<li>
|
||||
{state.ticketId
|
||||
? <>Ticket: <strong>{openTickets.find((t) => t.$id === state.ticketId)?.topic || 'vorhandenes Ticket'}</strong></>
|
||||
: <>Neues Ticket: <strong>{state.newTicket?.topic}</strong></>}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{isWebsiteWithCustomer && hasPreviewAlready && (
|
||||
<p className="muted">
|
||||
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
|
||||
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
|
||||
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
|
||||
</p>
|
||||
)}
|
||||
{isWebsiteWithCustomer && !hasPreviewAlready && (
|
||||
<>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
||||
Preview einschalten
|
||||
</label>
|
||||
{previewOn && (
|
||||
<Field label="Subdomain" htmlFor="assign-subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
|
||||
<input id="assign-subdomain" className="input" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="row-buttons">
|
||||
<Button type="button" variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
||||
{busy ? 'Speichert …' : 'Abschließen'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="msg err" role="alert">{error}</p>}
|
||||
|
||||
<div className="row-buttons">
|
||||
{state.step !== 'kind' && (
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}>Zurück</Button>
|
||||
)}
|
||||
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
||||
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="text-red" style={{ marginTop: 10 }}>{error}</div>}
|
||||
|
||||
<div className="flex gap-2" style={{ marginTop: 16 }}>
|
||||
{state.step !== 'kind' && (
|
||||
<Button variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}><FaArrowLeft /> Zurück</Button>
|
||||
)}
|
||||
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
|
||||
<Button variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter <FaArrowRight /></Button>
|
||||
)}
|
||||
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
|
||||
<Button type="button" variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -403,5 +421,5 @@ function TicketLink({ ticketId }) {
|
||||
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
||||
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
||||
}, [ticketId])
|
||||
return woid ? <Link to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
|
||||
return woid ? <Link className="link" to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { PageHeader, Card, Button } from '../components/ui'
|
||||
import { PageHeader, Button } from '../components/ui'
|
||||
import { completeBankAuth } from '../lib/financeApi'
|
||||
import { dateDe } from '../lib/financeFormat'
|
||||
import { parseCallback, successResult, failureResult, isResult } from '../lib/bankCallback'
|
||||
@@ -38,28 +38,25 @@ export default function BankCallbackPage() {
|
||||
if (result) {
|
||||
body = (
|
||||
<>
|
||||
<p role={result.ok ? 'status' : 'alert'} style={{ fontWeight: 600 }}>
|
||||
<p className={`msg ${result.ok ? 'ok' : 'err'} fin-result`} role={result.ok ? 'status' : 'alert'}>
|
||||
{result.ok
|
||||
? `Bankzugang erneuert, gültig bis ${dateDe(result.validUntil)}`
|
||||
: result.message}
|
||||
</p>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="fin-form-actions">
|
||||
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else if (params) {
|
||||
body = (
|
||||
<div className="text-center" role="status">
|
||||
<div className="spinner" />
|
||||
<p className="muted">Bankzugang wird eingerichtet …</p>
|
||||
</div>
|
||||
<div className="loading" role="status">Bankzugang wird eingerichtet …</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<p className="muted">Zu dieser Seite liegt keine Rückmeldung der Bank vor.</p>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="fin-form-actions">
|
||||
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -69,7 +66,7 @@ export default function BankCallbackPage() {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Bankzugang" />
|
||||
<Card>{body}</Card>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default function CreditsPage() {
|
||||
<div className="page">
|
||||
<PageHeader title="Gutschriften" subtitle="Übernahmegutschriften: bereits beim alten Anbieter bezahlte Leistungen verrechnen" />
|
||||
<CreditsView />
|
||||
<p className="area-muted" style={{ marginTop: 12 }}>
|
||||
<p className="table-foot">
|
||||
Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { FaArrowLeft, FaRotateRight } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button, EmptyState, AutocompleteInput } from '../components/ui'
|
||||
import ChatThread from '../components/chat/ChatThread'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
@@ -64,43 +63,40 @@ export default function CustomerChatPage() {
|
||||
<div className={`chat-page ${kunde ? 'has-thread' : ''}`}>
|
||||
<div className="chat-side">
|
||||
<AutocompleteInput value={query} onChange={onPick} getSuggestions={suggestions} placeholder="Neue Nachricht an Kunde…" />
|
||||
<Card pad={false}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 wrap" style={{ padding: 12 }}>
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={() => setReload((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
|
||||
<div className="chat-list">
|
||||
{sorted.map((c) => (
|
||||
<button
|
||||
key={c.customerId}
|
||||
type="button"
|
||||
className={`chat-list-item ${c.customerId === kunde ? 'active' : ''}`}
|
||||
onClick={() => open(c.customerId)}
|
||||
>
|
||||
<span className="chat-list-top">
|
||||
<strong>{c.customerName}</strong>
|
||||
{c.unreadFromCustomer > 0 && <span className="side-count">{c.unreadFromCustomer}</span>}
|
||||
</span>
|
||||
<span className="chat-list-last">{lastLine(c)}</span>
|
||||
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span className="msg err" role="alert">{error}</span>
|
||||
<Button onClick={() => setReload((k) => k + 1)}>Erneut versuchen</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
|
||||
<div className="chat-list">
|
||||
{sorted.map((c) => (
|
||||
<button
|
||||
key={c.customerId}
|
||||
type="button"
|
||||
className="chat-list-item"
|
||||
aria-current={c.customerId === kunde ? 'true' : undefined}
|
||||
onClick={() => open(c.customerId)}
|
||||
>
|
||||
<span className="chat-list-top">
|
||||
<strong>{c.customerName}</strong>
|
||||
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
|
||||
</span>
|
||||
<span className="chat-list-last">{lastLine(c)}</span>
|
||||
{c.unreadFromCustomer > 0 && <span className="chat-unread">{c.unreadFromCustomer}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-main">
|
||||
{kunde ? (
|
||||
<Card title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="chat-back" onClick={() => setParams({})}><FaArrowLeft /> Alle Unterhaltungen</Button>
|
||||
{nameOf(kunde)}
|
||||
</span>
|
||||
}>
|
||||
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
|
||||
</Card>
|
||||
<>
|
||||
<Button className="chat-back" onClick={() => setParams({})}>← Alle Unterhaltungen</Button>
|
||||
<Card title={nameOf(kunde)}>
|
||||
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card><EmptyState title="Unterhaltung links auswählen" hint="Oder oben eine neue Nachricht an einen Kunden beginnen." /></Card>
|
||||
)}
|
||||
|
||||
+163
-122
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaBoxArchive, FaBoxOpen, FaTrash } 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 { PageHeader, Card, Button, Badge, Tabs, EmptyState } from '../components/ui'
|
||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||
@@ -11,7 +10,11 @@ import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||||
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
|
||||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
|
||||
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'
|
||||
@@ -24,6 +27,12 @@ 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)
|
||||
@@ -53,13 +62,14 @@ export default function CustomerDetailPage() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
|
||||
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurück</Button>} /></div>
|
||||
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)
|
||||
@@ -77,28 +87,30 @@ export default function CustomerDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
title={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
|
||||
{customer.name}
|
||||
{stage === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||
{stage === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||
{archived && <Badge tone="muted"><FaBoxArchive /> Archiviert</Badge>}
|
||||
</span>
|
||||
}
|
||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
|
||||
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} />
|
||||
<Button variant="ghost" onClick={toggleArchived}>
|
||||
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
|
||||
</Button>
|
||||
{isAdmin && <Button variant="danger" onClick={removeCustomer}><FaTrash /> Kunde löschen</Button>}
|
||||
<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -113,7 +125,7 @@ export default function CustomerDetailPage() {
|
||||
{activeTab === 'abo' && (
|
||||
<>
|
||||
<AboStatusCard customerId={customer.$id} />
|
||||
<div style={{ marginTop: 16 }}><OffersView customerId={customer.$id} customerName={customer.name} /></div>
|
||||
<div className="kunde-abo-angebote"><OffersView customerId={customer.$id} customerName={customer.name} /></div>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
|
||||
@@ -141,7 +153,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
importantCustomer: !!customer.importantCustomer,
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||
|
||||
const ready = isInvoiceReady({ ...customer, ...form })
|
||||
|
||||
@@ -149,64 +161,76 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
setBusy(true); setMsg(null)
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||
setMsg('Gespeichert')
|
||||
setMsg({ tone: 'ok', text: 'Gespeichert' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setMsg('Fehler: ' + err.message)
|
||||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const upgrade = async () => {
|
||||
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
|
||||
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, form)
|
||||
setMsg('Zum festen Kunden gemacht.')
|
||||
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setMsg('Fehler: ' + err.message)
|
||||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
||||
<Card title="Stammdaten">
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Unternehmen / Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></Field>
|
||||
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
|
||||
</div>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
|
||||
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
|
||||
</div>
|
||||
<Field label="Standort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
|
||||
<Field label="Ansprechpartner"><input className="form-control" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></Field>
|
||||
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
|
||||
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
||||
Wichtiger Kunde
|
||||
</label>
|
||||
<div className="flex gap-2 items-center wrap">
|
||||
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
|
||||
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}><FaArrowUp /> Zum festen Kunden machen</Button>}
|
||||
{msg && <span className="muted">{msg}</span>}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="two-col">
|
||||
<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>}
|
||||
<Msg msg={msg} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Rechnungsdaten">
|
||||
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Adresse unvollständig</div>}
|
||||
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
|
||||
<Field label="Stadt"><input className="form-control" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></Field>
|
||||
</div>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
|
||||
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Card>
|
||||
<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-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>
|
||||
|
||||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||||
<div>
|
||||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -214,7 +238,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
function PortalAccessCard({ customer, onSaved }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||
|
||||
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||||
const currentPassword = getCustomerPortalPassword(customer)
|
||||
@@ -230,21 +254,21 @@ function PortalAccessCard({ customer, onSaved }) {
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!customer.email) {
|
||||
setMsg('Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.')
|
||||
setMsg({ tone: 'warn', text: 'Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.' })
|
||||
return
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
setMsg('Das Passwort muss mindestens 8 Zeichen haben.')
|
||||
setMsg({ tone: 'warn', text: '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.')
|
||||
setMsg({ tone: 'ok', text: hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setMsg('Fehler: ' + err.message)
|
||||
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -252,44 +276,41 @@ function PortalAccessCard({ customer, onSaved }) {
|
||||
|
||||
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>
|
||||
<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="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
||||
<span className="muted">Letzter Login</span>
|
||||
<span>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</span>
|
||||
</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'}</>}
|
||||
<div className="toolbar">
|
||||
<Button onClick={savePassword} disabled={busy}>
|
||||
{busy ? 'Speichert …' : hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}
|
||||
</Button>
|
||||
<KundenansichtButton customer={customer} variant="ghost" />
|
||||
{msg && <span className="muted">{msg}</span>}
|
||||
<KundenansichtButton customer={customer} />
|
||||
<Msg msg={msg} />
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
<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>
|
||||
@@ -298,6 +319,7 @@ function PortalAccessCard({ customer, onSaved }) {
|
||||
}
|
||||
|
||||
function CustomerTickets({ customerId }) {
|
||||
const navigate = useNavigate()
|
||||
const [tickets, setTickets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -321,25 +343,44 @@ function CustomerTickets({ customerId }) {
|
||||
return () => { active = false }
|
||||
}, [customerId])
|
||||
|
||||
if (loading) return <Card><FaSpinner className="spinner" /></Card>
|
||||
if (tickets.length === 0) return <Card><EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." /></Card>
|
||||
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 (
|
||||
<Card pad={false}>
|
||||
<div className="list">
|
||||
{tickets.map((t) => (
|
||||
<div key={t.$id} className="list-row">
|
||||
<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}</div>
|
||||
</div>
|
||||
<UrgentPill priority={t.priority} />
|
||||
<StatusPill status={t.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -359,7 +400,7 @@ function CustomerProjects({ customerId }) {
|
||||
return () => { active = false }
|
||||
}, [customerId, fetchAllProjects])
|
||||
|
||||
if (loading) return <Card><FaSpinner className="spinner" /></Card>
|
||||
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 (
|
||||
|
||||
+162
-129
@@ -1,37 +1,74 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FaSpinner, FaPlus, FaStar } from 'react-icons/fa6'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { createLead, customerStage, isArchived } from '../lib/leads'
|
||||
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
||||
import { createLead } from '../lib/leads'
|
||||
import { CUSTOMER_TABS, customerBadges, customerTitle, filterCustomers, stageCounts } from '../lib/customerList'
|
||||
import { isRowActivation } from '../lib/rowClick'
|
||||
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { customers, loading, refresh, createCustomer } = useCustomers()
|
||||
const { user, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
// 105 von 110 Eintraegen sind Leads der Lead-Automatik, deshalb startet die Liste bei den festen Kunden
|
||||
const [stage, setStage] = useState('customer') // customer | lead | all | archived
|
||||
const [stage, setStage] = useState('customer') // customer | lead | archived | all
|
||||
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return (customers || [])
|
||||
.filter((c) => {
|
||||
const archived = isArchived(c)
|
||||
// Archivierte nur im Archiviert-Tab, sonst nie
|
||||
if (stage === 'archived') return archived
|
||||
if (archived) return false
|
||||
const st = customerStage(c)
|
||||
if (st === 'lost') return stage === 'all'
|
||||
if (stage === 'all') return true
|
||||
return st === stage
|
||||
})
|
||||
.filter((c) => {
|
||||
if (!search) return true
|
||||
const s = search.toLowerCase()
|
||||
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
|
||||
})
|
||||
}, [customers, search, stage])
|
||||
const filtered = useMemo(() => filterCustomers(customers, { stage, search }), [customers, stage, search])
|
||||
// Zähler mit derselben Funktion wie die Liste (beachten den Suchtext); bis zum Laden ohne Zahl
|
||||
const counts = useMemo(() => stageCounts(customers, search), [customers, search])
|
||||
const tabs = CUSTOMER_TABS.map((tab) => ({ id: tab.id, label: tab.label, count: loading ? undefined : counts[tab.id] }))
|
||||
|
||||
let content
|
||||
if (loading) {
|
||||
content = <div className="loading">Wird geladen …</div>
|
||||
} else if (filtered.length === 0) {
|
||||
content = <EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
|
||||
} else {
|
||||
content = (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unternehmen</th>
|
||||
<th className="hide-sm num">Kundennummer</th>
|
||||
<th>Standort</th>
|
||||
<th className="hide-md muted">E-Mail</th>
|
||||
<th className="hide-md muted nowrap num">Telefon</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((c) => {
|
||||
const path = `/customers/${c.$id}`
|
||||
return (
|
||||
<tr
|
||||
key={c.$id}
|
||||
className="row"
|
||||
onClick={(e) => {
|
||||
if (isRowActivation(e)) navigate(path)
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<div className="cell-title">
|
||||
<Link className="row-link" to={path}>{customerTitle(c)}</Link>
|
||||
{customerBadges(c).map((b) => (
|
||||
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{c.contactName && <div className="cell-sub">{c.contactName}</div>}
|
||||
</td>
|
||||
<td className="hide-sm num">{c.code || <span className="faint">–</span>}</td>
|
||||
<td>{c.location || '–'}</td>
|
||||
<td className="hide-md muted">{c.email || '–'}</td>
|
||||
<td className="hide-md muted nowrap num">{c.phone || '–'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -41,89 +78,54 @@ export default function CustomersPage() {
|
||||
actions={
|
||||
<>
|
||||
{isAdmin && (
|
||||
<Button onClick={() => setShowCreate((s) => (s === 'customer' ? null : 'customer'))}><FaPlus /> Neuer Kunde</Button>
|
||||
<Button variant="primary" type="button" onClick={() => setShowCreate('customer')}>Neuer Kunde</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => setShowCreate((s) => (s === 'lead' ? null : 'lead'))}><FaPlus /> Neuer Lead</Button>
|
||||
<Button type="button" onClick={() => setShowCreate('lead')}>Neuer Lead</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs tabs={tabs} active={stage} onChange={setStage} />
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="input search"
|
||||
type="search"
|
||||
placeholder="Kunde suchen …"
|
||||
aria-label="Kunde suchen"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{content}
|
||||
|
||||
{showCreate === 'lead' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CreateLeadForm
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onDone={async (data) => {
|
||||
const r = await createLead(data, user)
|
||||
setShowCreate(null)
|
||||
refresh()
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CreateLeadForm
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onDone={async (data) => {
|
||||
const r = await createLead(data, user)
|
||||
setShowCreate(null)
|
||||
refresh()
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreate === 'customer' && isAdmin && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CreateCustomerForm
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onCreate={async (data) => {
|
||||
const r = await createCustomer(data)
|
||||
if (r.success) {
|
||||
setShowCreate(null)
|
||||
setStage('customer')
|
||||
refresh()
|
||||
}
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CreateCustomerForm
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onCreate={async (data) => {
|
||||
const r = await createCustomer(data)
|
||||
if (r.success) {
|
||||
setShowCreate(null)
|
||||
setStage('customer')
|
||||
refresh()
|
||||
}
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card pad={false}>
|
||||
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="seg">
|
||||
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
|
||||
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
|
||||
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
|
||||
<button className={stage === 'archived' ? 'active' : ''} onClick={() => setStage('archived')}>Archiviert</button>
|
||||
</div>
|
||||
<input className="form-control search-input" placeholder="Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
|
||||
</div>
|
||||
<div className="ui-card-body" style={{ padding: 0 }}>
|
||||
{loading ? (
|
||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
|
||||
) : (
|
||||
<div className="list">
|
||||
{filtered.map((c) => {
|
||||
const st = customerStage(c)
|
||||
return (
|
||||
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
|
||||
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
|
||||
{(c.name || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{c.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} title="Wichtiger Kunde" />}
|
||||
{c.name}
|
||||
{c.code && <span className="faint" style={{ fontWeight: 400 }}>· {c.code}</span>}
|
||||
</div>
|
||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
|
||||
{isArchived(c) && <Badge tone="muted">Archiviert</Badge>}
|
||||
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -147,22 +149,33 @@ function CreateLeadForm({ onCancel, onDone }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Neuer Lead (potenzieller Kunde)">
|
||||
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
|
||||
<Dialog
|
||||
as="form"
|
||||
title="Neuer Lead (potenzieller Kunde)"
|
||||
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 …' : 'Lead anlegen + Akquise-Ticket'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
<p className="muted small">
|
||||
Fuer einen Lead reichen Unternehmen und E-Mail. Es wird automatisch ein Akquise-Ticket erstellt.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Unternehmen"><input className="form-control" value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
|
||||
<Field label="E-Mail"><input className="form-control" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
|
||||
</div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Lead anlegen + Akquise-Ticket'}</Button>
|
||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<div className="field-row">
|
||||
<Field label="Unternehmen" htmlFor="lead-company">
|
||||
<input id="lead-company" className="input" value={company} onChange={(e) => setCompany(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="E-Mail" htmlFor="lead-email">
|
||||
<input id="lead-email" className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,24 +198,44 @@ export function CreateCustomerForm({ onCancel, onCreate }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Neuer Kunde mit Portal-Zugang">
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Unternehmen / Name"><input className="form-control" name="name" value={form.name} onChange={set('name')} required /></Field>
|
||||
<Field label="E-Mail (Portal-Login)"><input className="form-control" type="email" name="email" value={form.email} onChange={set('email')} required /></Field>
|
||||
<Field label="Kundennummer"><input className="form-control" name="code" value={form.code} onChange={set('code')} /></Field>
|
||||
</div>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Telefon"><input className="form-control" name="phone" value={form.phone} onChange={set('phone')} /></Field>
|
||||
<Field label="Standort"><input className="form-control" name="location" value={form.location} onChange={set('location')} /></Field>
|
||||
<Field label="Portal-Passwort (mind. 8 Zeichen)"><input className="form-control" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required /></Field>
|
||||
</div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Kunde anlegen'}</Button>
|
||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Dialog
|
||||
as="form"
|
||||
wide
|
||||
title="Neuer Kunde mit Portal-Zugang"
|
||||
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 …' : 'Kunde anlegen'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
<div className="field-row-3">
|
||||
<Field label="Unternehmen / Name" htmlFor="customer-name">
|
||||
<input id="customer-name" className="input" name="name" value={form.name} onChange={set('name')} required />
|
||||
</Field>
|
||||
<Field label="E-Mail (Portal-Login)" htmlFor="customer-email">
|
||||
<input id="customer-email" className="input" type="email" name="email" value={form.email} onChange={set('email')} required />
|
||||
</Field>
|
||||
<Field label="Kundennummer" htmlFor="customer-code">
|
||||
<input id="customer-code" className="input" name="code" value={form.code} onChange={set('code')} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="field-row-3">
|
||||
<Field label="Telefon" htmlFor="customer-phone">
|
||||
<input id="customer-phone" className="input" name="phone" value={form.phone} onChange={set('phone')} />
|
||||
</Field>
|
||||
<Field label="Standort" htmlFor="customer-location">
|
||||
<input id="customer-location" className="input" name="location" value={form.location} onChange={set('location')} />
|
||||
</Field>
|
||||
<Field label="Portal-Passwort (mind. 8 Zeichen)" htmlFor="customer-password">
|
||||
<input id="customer-password" className="input" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required />
|
||||
</Field>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+16
-17
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { FaGear, FaSpinner } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button } from '../components/ui'
|
||||
import { PageHeader, Button } from '../components/ui'
|
||||
import StatusLine from '../components/finance/StatusLine'
|
||||
import LiquidityHead from '../components/finance/LiquidityHead'
|
||||
import TodoList from '../components/finance/TodoList'
|
||||
@@ -16,6 +15,7 @@ import { useFinanceTransactions } from '../hooks/useFinanceTransactions'
|
||||
import { useOpenInvoices } from '../hooks/useOpenInvoices'
|
||||
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
||||
import { planTodoAction, safeUrl } from '../lib/financeView'
|
||||
import { noticeProps } from '../lib/financeUi'
|
||||
|
||||
export default function FinancePage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -139,29 +139,30 @@ export default function FinancePage() {
|
||||
let body
|
||||
if (!data && loading) {
|
||||
body = (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Finanzen werden geladen …</p>
|
||||
<div className="loading" role="status">
|
||||
Finanzen werden geladen …
|
||||
</div>
|
||||
)
|
||||
} else if (!data) {
|
||||
body = (
|
||||
<Card>
|
||||
<p role="alert" style={{ fontWeight: 600, marginTop: 0 }}>{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
|
||||
<div className="fin-load-error">
|
||||
<p className="msg err" role="alert">{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
|
||||
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<div className="fin-stack">
|
||||
{error && (
|
||||
<p className="fin-hint fin-hint-warn" role="alert">
|
||||
<p {...noticeProps('warn')}>
|
||||
Die Übersicht konnte nicht neu geladen werden: {error}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={reload} disabled={loading}>Erneut versuchen</Button>
|
||||
<button type="button" className="link" onClick={reload} disabled={loading}>Erneut versuchen</button>
|
||||
</p>
|
||||
)}
|
||||
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
||||
<LiquidityHead kopf={data.kopf} />
|
||||
<div className="fin-top">
|
||||
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
||||
<LiquidityHead kopf={data.kopf} />
|
||||
</div>
|
||||
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
|
||||
<div className="fin-cols">
|
||||
<ForecastTable
|
||||
@@ -195,15 +196,13 @@ export default function FinancePage() {
|
||||
title="Finanzen"
|
||||
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
|
||||
actions={
|
||||
<Button as={Link} variant="ghost" to="/finance/einstellungen">
|
||||
<FaGear aria-hidden="true" /> Einstellungen
|
||||
</Button>
|
||||
<Button as={Link} to="/finance/einstellungen">Einstellungen</Button>
|
||||
}
|
||||
/>
|
||||
{assignNotice && (
|
||||
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
|
||||
<p {...noticeProps('warn', 'fin-page-notice')}>
|
||||
{assignNotice}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={() => setAssignNotice('')}>Schließen</Button>
|
||||
<button type="button" className="link" onClick={() => setAssignNotice('')}>Schließen</button>
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FaArrowLeft } from 'react-icons/fa6'
|
||||
import { PageHeader, Button } from '../components/ui'
|
||||
import SettingsCard from '../components/finance/SettingsCard'
|
||||
import BankAccessCard from '../components/finance/BankAccessCard'
|
||||
@@ -7,7 +6,7 @@ import FixedCostsCard from '../components/finance/FixedCostsCard'
|
||||
|
||||
/**
|
||||
* Finanzen / Einstellungen (/finance/einstellungen, nur Admins): Rücklagen und Bankverbindungstext, Bankzugang
|
||||
* (Status, Abruf, neu verbinden) und die Fixkostenliste. Jede Karte lädt und speichert für sich.
|
||||
* (Status, Abruf, neu verbinden) und die Fixkostenliste. Jeder Abschnitt lädt und speichert für sich.
|
||||
*/
|
||||
export default function FinanceSettingsPage() {
|
||||
return (
|
||||
@@ -16,8 +15,8 @@ export default function FinanceSettingsPage() {
|
||||
title="Finanzen: Einstellungen"
|
||||
subtitle="Rücklagen, Bankzugang und Fixkosten"
|
||||
actions={
|
||||
<Button as={Link} variant="ghost" to="/finance">
|
||||
<FaArrowLeft aria-hidden="true" /> Zurück zu den Finanzen
|
||||
<Button as={Link} to="/finance">
|
||||
← Zurück zu den Finanzen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function LegalPage() {
|
||||
const active = tab === 'texte' ? texts : contracts
|
||||
|
||||
let body
|
||||
if (active.loading) body = <div className="faint">Lädt …</div>
|
||||
if (active.loading) body = <div className="loading">Lädt …</div>
|
||||
else if (active.missingSetup) {
|
||||
body = (
|
||||
<EmptyState
|
||||
@@ -31,7 +31,7 @@ export default function LegalPage() {
|
||||
<div className="page">
|
||||
<PageHeader title="Rechtliches" subtitle="Rechtstexte mit Versionsgeschichte und Verträge mit Anbietern" />
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
{active.error && <div className="legal-error">{active.error}</div>}
|
||||
{active.error && <div className="msg err" role="alert">{active.error}</div>}
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function OffersPage() {
|
||||
<div className="page">
|
||||
<PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" />
|
||||
<OffersView />
|
||||
<p className="area-muted" style={{ marginTop: 12 }}>Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
|
||||
<p className="table-foot">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+332
-469
@@ -1,18 +1,9 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import PreviewBlock from '../components/PreviewBlock'
|
||||
import {
|
||||
FaFolder,
|
||||
FaPlus,
|
||||
FaArrowsRotate,
|
||||
FaPen,
|
||||
FaBoxArchive,
|
||||
FaBoxOpen,
|
||||
FaGlobe,
|
||||
FaLock,
|
||||
FaCircleInfo,
|
||||
FaClone,
|
||||
} from 'react-icons/fa6'
|
||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useWorkorders } from '../hooks/useWorkorders'
|
||||
@@ -27,70 +18,22 @@ import {
|
||||
importNewReposThrottled,
|
||||
} from '../lib/projectAdminApi'
|
||||
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
|
||||
import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
|
||||
import { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
|
||||
import { PROJECT_TYPES, typeOf, projectActions, projectTicketRef, updatedLabel } from '../lib/projectRow'
|
||||
import { ticketPath } from '../lib/ticketRoutes'
|
||||
import { isRowActivation } from '../lib/rowClick'
|
||||
import { slugify } from '../lib/subdomain'
|
||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||
|
||||
const WORKORDER_FILTERS = { limit: 500 }
|
||||
|
||||
// Projekt-Typen (= "Template"/Hosting-Modus)
|
||||
const TYPES = {
|
||||
preview: {
|
||||
label: 'Preview (mit Login)',
|
||||
short: 'Preview',
|
||||
badge: 'badge-info',
|
||||
icon: <FaLock />,
|
||||
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
|
||||
},
|
||||
website: {
|
||||
label: 'Oeffentliche Website',
|
||||
short: 'Website',
|
||||
badge: 'badge-ok',
|
||||
icon: <FaGlobe />,
|
||||
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
|
||||
},
|
||||
template: {
|
||||
label: 'Vorlage',
|
||||
short: 'Vorlage',
|
||||
badge: 'badge-warn',
|
||||
icon: <FaClone />,
|
||||
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
|
||||
adminOnly: true,
|
||||
},
|
||||
project: {
|
||||
label: 'Internes Projekt (kein Hosting)',
|
||||
short: 'Projekt',
|
||||
badge: 'badge-muted',
|
||||
icon: <FaFolder />,
|
||||
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
|
||||
},
|
||||
}
|
||||
|
||||
function typeOf(project) {
|
||||
if (project.isTemplate) return 'template'
|
||||
if (project.isPublic) return 'website'
|
||||
if (project.subdomain) return 'preview'
|
||||
return 'project'
|
||||
}
|
||||
|
||||
// Typen mit Hosting (Subdomain + Deploy)
|
||||
const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
|
||||
|
||||
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
|
||||
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
|
||||
function isProjectReady(project) {
|
||||
const st = String(project.provisioningStatus || project.status || '').toLowerCase()
|
||||
return ['ready', 'deployed'].includes(st)
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
|
||||
const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
|
||||
const { customers } = useCustomers()
|
||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||
const { employees } = useEmployees()
|
||||
const { isAdmin } = useAuth()
|
||||
const [loadedOnce, setLoadedOnce] = useState(false)
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [customerFilter, setCustomerFilter] = useState('')
|
||||
@@ -121,21 +64,13 @@ export default function ProjectsPage() {
|
||||
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchAllProjects()
|
||||
fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) })
|
||||
importNewReposThrottled()
|
||||
.then((r) => { if (r && !cancelled) fetchAllProjects() })
|
||||
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
|
||||
return () => { cancelled = true }
|
||||
}, [fetchAllProjects])
|
||||
|
||||
const customerMap = useMemo(() => {
|
||||
const map = {}
|
||||
for (const c of customers) {
|
||||
map[c.$id] = c.name || c.code || c.$id
|
||||
}
|
||||
return map
|
||||
}, [customers])
|
||||
|
||||
const ticketMap = useMemo(() => {
|
||||
const map = {}
|
||||
for (const wo of workorders) {
|
||||
@@ -355,199 +290,57 @@ export default function ProjectsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: `Aktive Projekte (${counts.all})` },
|
||||
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
||||
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
||||
// Vorlagen-Ansicht nur fuer Admins
|
||||
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
||||
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
||||
{ value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` },
|
||||
]
|
||||
// Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||
const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||||
|
||||
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Website-Projekte</h2>
|
||||
</header>
|
||||
|
||||
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
type="search"
|
||||
className="form-control"
|
||||
placeholder="Nach Titel suchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
|
||||
/>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: 'auto', minWidth: '170px' }}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
{FILTER_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: 'auto', minWidth: '150px' }}
|
||||
value={customerFilter}
|
||||
onChange={(e) => setCustomerFilter(e.target.value)}
|
||||
title="Nach Kunde filtern"
|
||||
>
|
||||
<option value="">Alle Kunden</option>
|
||||
{sortedCustomers.map((c) => (
|
||||
<option key={c.$id} value={c.$id}>
|
||||
{c.name}{c.code ? ` (${c.code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: 'auto', minWidth: '150px' }}
|
||||
value={employeeFilter}
|
||||
onChange={(e) => setEmployeeFilter(e.target.value)}
|
||||
title="Nach Mitarbeiter filtern"
|
||||
>
|
||||
<option value="">Alle Mitarbeiter</option>
|
||||
{sortedEmployees.map((emp) => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName || emp.userId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-teal"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
title="Neues Projekt"
|
||||
aria-label="Neues Projekt"
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-dark"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={handleSyncRepos}
|
||||
disabled={syncLoading}
|
||||
title="Gitea-Repos synchronisieren"
|
||||
aria-label="Gitea-Repos synchronisieren"
|
||||
>
|
||||
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-grey">{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hiddenHits.length > 0 && (
|
||||
<div className="text-center mb-2" data-hidden-hits>
|
||||
<span className="text-grey">Weitere Treffer: </span>
|
||||
{hiddenHits.map((h, i) => (
|
||||
<span key={h.view}>
|
||||
{i > 0 && <span className="text-grey"> · </span>}
|
||||
<button
|
||||
type="button"
|
||||
style={{ padding: 0, border: 'none', background: 'none', color: 'var(--accent)', textDecoration: 'underline', cursor: 'pointer', font: 'inherit' }}
|
||||
onClick={() => setFilter(h.view)}
|
||||
>
|
||||
{h.count} unter „{VIEW_LABELS[h.view]}“
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-grey">Projekte werden geladen...</p>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="text-center p-4">
|
||||
<FaFolder size={64} className="text-grey" />
|
||||
<p className="text-grey mt-2">
|
||||
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
|
||||
gap: '16px',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
let content
|
||||
if (!loadedOnce) {
|
||||
// Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen
|
||||
content = <div className="loading">Wird geladen …</div>
|
||||
} else if (filteredProjects.length === 0) {
|
||||
content = <EmptyState title={filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'} />
|
||||
} else {
|
||||
content = (
|
||||
<table className="table projects-table" role="table">
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader">Projekt</th>
|
||||
<th role="columnheader">Kunde</th>
|
||||
<th role="columnheader" className="hide-md">Art</th>
|
||||
<th role="columnheader" className="hide-md">Adresse</th>
|
||||
<th role="columnheader" className="hide-sm num">Ticket</th>
|
||||
<th role="columnheader">Status</th>
|
||||
<th role="columnheader" className="hide-md right">Zuletzt geändert</th>
|
||||
<th role="columnheader" className="right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{filteredProjects.map((project) => {
|
||||
const type = typeOf(project)
|
||||
const t = TYPES[type]
|
||||
const hosted = HOSTED_TYPES.has(type)
|
||||
const ready = isProjectReady(project)
|
||||
const needsHosting = hosted && !ready && !project.archived
|
||||
const actions = projectActions(project)
|
||||
const archived = Boolean(project.archived)
|
||||
const type = PROJECT_TYPES[actions.type]
|
||||
const name = project.projectName || project.giteaRepoName || '(ohne Name)'
|
||||
const ticketRef = projectTicketRef(project, ticketMap)
|
||||
const updated = updatedLabel(project.$updatedAt)
|
||||
return (
|
||||
<div
|
||||
<tr
|
||||
key={project.$id}
|
||||
className="card"
|
||||
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
|
||||
role="row"
|
||||
className={`row${archived ? ' is-archived' : ''}`}
|
||||
onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
|
||||
{project.projectName || project.giteaRepoName || '(ohne Name)'}
|
||||
</div>
|
||||
{project.repoFullName && (
|
||||
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
|
||||
{project.repoFullName}
|
||||
</div>
|
||||
)}
|
||||
<td role="cell">
|
||||
<div className="cell-title">
|
||||
<button type="button" className="link" onClick={() => openEdit(project)}>{name}</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}>
|
||||
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}>
|
||||
{t.icon} {t.short}
|
||||
</span>
|
||||
{hosted && !project.archived && (
|
||||
<span className={`badge ${ready ? 'badge-ok' : 'badge-warn'}`} style={{ whiteSpace: 'nowrap' }}>
|
||||
{ready ? 'gehostet' : 'nicht deployt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
|
||||
<Row label="Subdomain">
|
||||
{project.subdomain
|
||||
? <code>{project.subdomain}</code>
|
||||
: <span className="text-grey">ohne Website</span>}
|
||||
</Row>
|
||||
<Row label="Ticket">
|
||||
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
|
||||
</Row>
|
||||
<Row label="Status">
|
||||
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
|
||||
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
|
||||
</td>
|
||||
<td role="cell" data-label="Kunde">
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ fontSize: '13px' }}
|
||||
className="select"
|
||||
aria-label={`Kunde von ${name}`}
|
||||
value={project.customerId || ''}
|
||||
disabled={assignBusyId === project.$id || project.archived}
|
||||
disabled={assignBusyId === project.$id || archived}
|
||||
onChange={(e) => handleAssignCustomer(project, e.target.value)}
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
@@ -557,234 +350,304 @@ export default function ProjectsPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-dark"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => openEdit(project)}
|
||||
title="Bearbeiten"
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<FaPen />
|
||||
</button>
|
||||
{needsHosting ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-teal"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => handleHostNow(project)}
|
||||
disabled={hostBusyId === project.$id}
|
||||
title="Projekt jetzt deployen/hosten"
|
||||
aria-label="Projekt jetzt deployen/hosten"
|
||||
>
|
||||
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||
</button>
|
||||
) : (
|
||||
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
||||
)}
|
||||
{hosted && ready && !project.archived && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-dark"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => handleRefresh(project)}
|
||||
disabled={refreshBusyId === project.$id}
|
||||
title="Aktualisieren – Website neu deployen"
|
||||
aria-label="Aktualisieren – Website neu deployen"
|
||||
>
|
||||
<FaArrowsRotate
|
||||
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell" className="hide-md">
|
||||
<Badge tone={type.tone}>{type.short}</Badge>
|
||||
</td>
|
||||
<td role="cell" className="hide-md cell-address">
|
||||
{project.subdomain
|
||||
? <code title={project.subdomain}>{project.subdomain}</code>
|
||||
: <span className="muted">ohne Website</span>}
|
||||
</td>
|
||||
<td role="cell" className="hide-sm num">
|
||||
{ticketRef
|
||||
? <Link className="link" to={ticketPath(ticketRef)}>{ticketRef}</Link>
|
||||
: <span className="faint">-</span>}
|
||||
</td>
|
||||
<td role="cell" data-label="Status">
|
||||
{actions.hostedBadge && <Badge tone={actions.hostedBadge.tone}>{actions.hostedBadge.text}</Badge>}
|
||||
<div className="cell-sub">{archived ? 'archiviert' : (project.status || '-')}</div>
|
||||
</td>
|
||||
<td role="cell" className="hide-md right cell-updated" title={updated.title || undefined}>
|
||||
{updated.title ? updated.text : <span className="faint">{updated.text}</span>}
|
||||
</td>
|
||||
<td role="cell" className="right cell-actions">
|
||||
<div className="row-actions">
|
||||
{actions.host && (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
title="Projekt jetzt deployen/hosten"
|
||||
onClick={() => handleHostNow(project)}
|
||||
disabled={hostBusyId === project.$id}
|
||||
>
|
||||
{hostBusyId === project.$id ? 'Wird geladen …' : 'Hosten'}
|
||||
</button>
|
||||
)}
|
||||
{actions.preview && <PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />}
|
||||
{actions.refresh && (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
title="Aktualisieren – Website neu deployen"
|
||||
onClick={() => handleRefresh(project)}
|
||||
disabled={refreshBusyId === project.$id}
|
||||
>
|
||||
{refreshBusyId === project.$id ? 'Wird geladen …' : 'Aktualisieren'}
|
||||
</button>
|
||||
)}
|
||||
{actions.gitea && <GiteaLinkButton href={project.giteaRepoUrl} />}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Website-Projekte"
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" onClick={handleSyncRepos} disabled={syncLoading}>
|
||||
{syncLoading ? 'Wird geladen …' : 'Gitea-Repos synchronisieren'}
|
||||
</Button>
|
||||
<Button variant="primary" type="button" onClick={() => setShowCreateModal(true)}>Neues Projekt</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs tabs={projectTabs(counts, { isAdmin })} active={filter} onChange={setFilter} />
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="input search"
|
||||
aria-label="Nach Titel suchen"
|
||||
placeholder="Nach Titel suchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="select select-quiet"
|
||||
value={customerFilter}
|
||||
onChange={(e) => setCustomerFilter(e.target.value)}
|
||||
title="Nach Kunde filtern"
|
||||
aria-label="Nach Kunde filtern"
|
||||
>
|
||||
<option value="">Alle Kunden</option>
|
||||
{sortedCustomers.map((c) => (
|
||||
<option key={c.$id} value={c.$id}>
|
||||
{c.name}{c.code ? ` (${c.code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select select-quiet"
|
||||
value={employeeFilter}
|
||||
onChange={(e) => setEmployeeFilter(e.target.value)}
|
||||
title="Nach Mitarbeiter filtern"
|
||||
aria-label="Nach Mitarbeiter filtern"
|
||||
>
|
||||
<option value="">Alle Mitarbeiter</option>
|
||||
{sortedEmployees.map((emp) => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName || emp.userId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{notice && <p className="msg muted" role="status">{notice}</p>}
|
||||
|
||||
{hiddenHits.length > 0 && (
|
||||
<p className="msg muted" data-hidden-hits>
|
||||
Weitere Treffer:{' '}
|
||||
{hiddenHits.map((h, i) => (
|
||||
<span key={h.view}>
|
||||
{i > 0 && ' · '}
|
||||
<button type="button" className="link" onClick={() => setFilter(h.view)}>
|
||||
{h.count} unter „{VIEW_LABELS[h.view]}“
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="msg err" role="alert">{error}</p>}
|
||||
|
||||
{content}
|
||||
|
||||
{/* --- Neues Projekt --- */}
|
||||
{showCreateModal && (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Neues Website-Projekt</h2>
|
||||
{createError && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={createForm.displayName}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Subdomain (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={createForm.subdomain}
|
||||
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
|
||||
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
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">
|
||||
<label className="form-label">Kunde (optional)</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={createForm.customerId}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.$id} value={c.$id}>
|
||||
({c.code || ''}) {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<button type="submit" className="btn btn-dark" disabled={createLoading}>
|
||||
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
as="form"
|
||||
onSubmit={handleCreate}
|
||||
title="Neues Website-Projekt"
|
||||
busy={createLoading}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
footer={
|
||||
<>
|
||||
<span className="grow" />
|
||||
<Button type="submit" variant="primary" disabled={createLoading}>
|
||||
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{createError && <p className="msg err" role="alert">{createError}</p>}
|
||||
<Field label="Anzeigename" htmlFor="projekt-neu-name">
|
||||
<input
|
||||
id="projekt-neu-name"
|
||||
type="text"
|
||||
className="input"
|
||||
value={createForm.displayName}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Subdomain (optional)"
|
||||
htmlFor="projekt-neu-subdomain"
|
||||
hint="Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt. Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt."
|
||||
>
|
||||
<input
|
||||
id="projekt-neu-subdomain"
|
||||
type="text"
|
||||
className="input"
|
||||
value={createForm.subdomain}
|
||||
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
|
||||
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||
placeholder="leer lassen = Projekt ohne Website"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Repo-Name (optional)" htmlFor="projekt-neu-repo">
|
||||
<input
|
||||
id="projekt-neu-repo"
|
||||
type="text"
|
||||
className="input"
|
||||
value={createForm.repoName}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
|
||||
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kunde (optional)" htmlFor="projekt-neu-kunde">
|
||||
<select
|
||||
id="projekt-neu-kunde"
|
||||
className="select"
|
||||
value={createForm.customerId}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.$id} value={c.$id}>
|
||||
({c.code || ''}) {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* --- Projekt bearbeiten --- */}
|
||||
{editProject && (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Projekt bearbeiten</h2>
|
||||
<p className="text-grey mb-2" style={{ fontSize: '13px' }}>
|
||||
{editProject.repoFullName}
|
||||
</p>
|
||||
{editError && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleEditSave}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={editForm.projectName}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Typ / Template</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={editForm.projectType}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
|
||||
>
|
||||
{typeOptions.map(([key, meta]) => (
|
||||
<option key={key} value={key}>{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="text-grey">
|
||||
<FaCircleInfo /> {TYPES[editForm.projectType].hint}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{editForm.projectType !== 'project' && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Subdomain</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={editForm.subdomain}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||
placeholder="z.B. musterfirma"
|
||||
/>
|
||||
<small className="text-grey">
|
||||
Ergebnis: https://{slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com
|
||||
{' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut).
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
|
||||
<Dialog
|
||||
wide
|
||||
title="Projekt bearbeiten"
|
||||
busy={editLoading || archiveBusy}
|
||||
onClose={() => setEditProject(null)}
|
||||
footer={
|
||||
<>
|
||||
{editProject.archived ? (
|
||||
<button
|
||||
type="button"
|
||||
className={editProject.archived ? 'btn' : 'link-danger'}
|
||||
className="link"
|
||||
onClick={() => handleArchiveToggle(editProject)}
|
||||
disabled={archiveBusy || editLoading}
|
||||
>
|
||||
{editProject.archived
|
||||
? <><FaBoxOpen /> Reaktivieren</>
|
||||
: <><FaBoxArchive /> Repository archivieren</>}
|
||||
Reaktivieren
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-dark"
|
||||
disabled={editLoading || archiveBusy || editProject.archived}
|
||||
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
type="button"
|
||||
onClick={() => handleArchiveToggle(editProject)}
|
||||
disabled={archiveBusy || editLoading}
|
||||
>
|
||||
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
{editProject.archived && (
|
||||
<small className="text-grey" style={{ display: 'block', marginTop: 8 }}>
|
||||
Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
|
||||
</small>
|
||||
Repository archivieren
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
|
||||
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
|
||||
<div className="form-group" style={{ marginTop: 16 }}>
|
||||
<label className="form-label">Preview</label>
|
||||
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
||||
</div>
|
||||
<span className="grow" />
|
||||
{/* Im Fuss ausserhalb des <form>, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */}
|
||||
<Button
|
||||
type="submit"
|
||||
form="projekt-bearbeiten"
|
||||
variant="primary"
|
||||
disabled={editLoading || archiveBusy || editProject.archived}
|
||||
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
|
||||
>
|
||||
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{editProject.repoFullName && <p className="muted small">{editProject.repoFullName}</p>}
|
||||
{editError && <p className="msg err" role="alert">{editError}</p>}
|
||||
<form id="projekt-bearbeiten" className="dialog-form" onSubmit={handleEditSave}>
|
||||
<Field label="Name" htmlFor="projekt-bearbeiten-name">
|
||||
<input
|
||||
id="projekt-bearbeiten-name"
|
||||
type="text"
|
||||
className="input"
|
||||
value={editForm.projectName}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Typ / Template" htmlFor="projekt-bearbeiten-typ" hint={PROJECT_TYPES[editForm.projectType].hint}>
|
||||
<select
|
||||
id="projekt-bearbeiten-typ"
|
||||
className="select"
|
||||
value={editForm.projectType}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
|
||||
>
|
||||
{typeOptions.map(([key, meta]) => (
|
||||
<option key={key} value={key}>{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{editForm.projectType !== 'project' && (
|
||||
<Field
|
||||
label="Subdomain"
|
||||
htmlFor="projekt-bearbeiten-subdomain"
|
||||
hint={`Ergebnis: https://${slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com - Aenderung wird neu deployt (alte Subdomain wird abgebaut).`}
|
||||
>
|
||||
<input
|
||||
id="projekt-bearbeiten-subdomain"
|
||||
type="text"
|
||||
className="input"
|
||||
value={editForm.subdomain}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||
placeholder="z.B. musterfirma"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{editProject.archived && <p className="muted small">Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.</p>}
|
||||
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
|
||||
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
|
||||
<section>
|
||||
<h4 className="pv-title">Preview</h4>
|
||||
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
||||
</section>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
|
||||
<span className="text-grey">{label}</span>
|
||||
<span style={{ textAlign: 'right', wordBreak: 'break-word' }}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useMemo, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { PageHeader } from '../components/ui'
|
||||
import { PageHeader, Badge, Stat } from '../components/ui'
|
||||
import { badgeToneClass } from '../components/ui/tones'
|
||||
import { serverpulsApi } from '../lib/serverpulsApi'
|
||||
import {
|
||||
ageLabel, formatBytes, formatNumber, formatPct, formatSince, usedPct,
|
||||
ageLabel, deltaDir, deltaSign, formatBytes, formatNumber, formatPct, formatSince, levelTone, usedPct,
|
||||
} from '../lib/serverpulsFormat'
|
||||
import Chart from '../components/serverpuls/Chart'
|
||||
import { CHART } from '../components/serverpuls/chartColors'
|
||||
@@ -81,20 +82,17 @@ function Tile({ id, label, model }) {
|
||||
let deltaEl = null
|
||||
if (model && model.delta !== null && model.delta !== undefined) {
|
||||
const d = model.delta
|
||||
const dir = Math.abs(d) < 1e-9 ? 'flat' : d > 0 ? 'up' : 'down'
|
||||
const sign = dir === 'up' ? '▲ +' : dir === 'down' ? '▼ −' : ''
|
||||
const dir = deltaDir(d)
|
||||
deltaEl = (
|
||||
<div className={`sp-tile-delta sp-delta-${dir}`}>
|
||||
{sign}{model.fmtDelta(d)} ggü. gestern
|
||||
{deltaSign(dir)}{model.fmtDelta(d)} ggü. gestern
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="sp-tile" data-testid={`sp-tile-${id}`}>
|
||||
<div className="sp-tile-label">{label}</div>
|
||||
<div className="sp-tile-value">{model ? model.main : '–'}</div>
|
||||
<div className="sp-tile-sub">{model?.sub || ' '}</div>
|
||||
<Sparkline points={model?.spark || []} width={200} height={36} />
|
||||
<Stat label={label} value={model ? model.main : '–'} sub={model?.sub || '\u00a0'} />
|
||||
<Sparkline points={model?.spark || []} width={120} height={32} />
|
||||
{deltaEl}
|
||||
</div>
|
||||
)
|
||||
@@ -102,40 +100,44 @@ function Tile({ id, label, model }) {
|
||||
|
||||
function Pills({ items, value, onChange, testid }) {
|
||||
return (
|
||||
<div className="sp-pills" data-testid={testid}>
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
className={`sp-pill ${value === it.id ? 'active' : ''}`}
|
||||
onClick={() => onChange(it.id)}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="seg" data-testid={testid}>
|
||||
{items.map((it) => {
|
||||
const active = value === it.id
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
className={active ? 'on' : undefined}
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(it.id)}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Cleanup({ data }) {
|
||||
if (!data || !data.day) return <div className="sp-muted">Noch keine Messung.</div>
|
||||
if (!data || !data.day) return <div className="muted">Noch keine Messung.</div>
|
||||
const stopped = Array.isArray(data.stopped) ? data.stopped : []
|
||||
return (
|
||||
<div className="sp-cleanup-body">
|
||||
<div className="sp-cleanup-grid">
|
||||
<div><span className="sp-muted">Ungenutzte Images</span><strong>{formatBytes(data.imagesBytes)}</strong></div>
|
||||
<div><span className="sp-muted">Build-Cache</span><strong>{formatBytes(data.buildCacheBytes)}</strong></div>
|
||||
<div><span className="sp-muted">Gestoppte Container</span><strong>{formatBytes(data.stoppedBytes)}</strong></div>
|
||||
<>
|
||||
<div className="stats sp-cleanup-stats">
|
||||
<Stat label="Ungenutzte Images" value={formatBytes(data.imagesBytes)} />
|
||||
<Stat label="Build-Cache" value={formatBytes(data.buildCacheBytes)} />
|
||||
<Stat label="Gestoppte Container" value={formatBytes(data.stoppedBytes)} />
|
||||
</div>
|
||||
{stopped.length > 0 && (
|
||||
<ul className="sp-cleanup-list">
|
||||
{stopped.map((s) => (
|
||||
<li key={s.name}><span>{s.name}</span><span>{formatBytes(s.bytes)}</span></li>
|
||||
<li key={s.name} className="list-row"><span>{s.name}</span><span className="num">{formatBytes(s.bytes)}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="sp-muted sp-small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</div>
|
||||
</div>
|
||||
<p className="muted small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +247,6 @@ export default function ServerpulsPage() {
|
||||
const level = overview?.level || 'ok'
|
||||
const alerts = overview?.alerts || []
|
||||
const age = ageLabel(overview?.lastSample ?? null, now)
|
||||
const dot = level === 'critical' ? '🔴' : level === 'medium' ? '🟠' : '🟢'
|
||||
const headline = level === 'ok'
|
||||
? 'Alles ok'
|
||||
: level === 'critical'
|
||||
@@ -256,14 +257,20 @@ export default function ServerpulsPage() {
|
||||
<div className="page sp-page" data-testid="sp-page">
|
||||
<PageHeader title="Serverpuls" subtitle="Zustand und Verlauf des Servers – nur Anzeige" />
|
||||
|
||||
{error && <div className="sp-error" data-testid="sp-error" role="alert">{error}</div>}
|
||||
{error && <div className="msg err" data-testid="sp-error" role="alert">{error}</div>}
|
||||
|
||||
<section className={`sp-head sp-level-${level}`} data-testid="sp-head">
|
||||
<section data-testid="sp-head">
|
||||
<div className="sp-head-top">
|
||||
<span className="sp-ampel" data-testid="sp-ampel" data-level={level}>{dot}</span>
|
||||
<span
|
||||
className={overview ? badgeToneClass(levelTone(level)) : 'badge plain'}
|
||||
data-testid="sp-ampel"
|
||||
data-level={level}
|
||||
>
|
||||
{overview ? (LEVEL_TEXT[level] || level) : '–'}
|
||||
</span>
|
||||
<strong className="sp-headline">{overview ? headline : (error ? 'Keine Daten' : 'Lädt …')}</strong>
|
||||
{overview && (
|
||||
<span className={`sp-age ${age.stale ? 'sp-age-stale' : ''}`} data-testid="sp-age" data-stale={age.stale ? '1' : '0'}>
|
||||
<span className={age.stale ? 'sp-age sp-age-stale' : 'sp-age'} data-testid="sp-age" data-stale={age.stale ? '1' : '0'}>
|
||||
Letzte Messung {age.text}{age.stale ? ' – Daten veraltet' : ''}
|
||||
</span>
|
||||
)}
|
||||
@@ -271,46 +278,46 @@ export default function ServerpulsPage() {
|
||||
{alerts.length > 0 && (
|
||||
<ul className="sp-alerts" data-testid="sp-alerts">
|
||||
{alerts.map((a) => (
|
||||
<li key={a.id} className={`sp-alert sp-alert-${a.level}`} data-testid="sp-alert">
|
||||
<span className="sp-alert-level">{LEVEL_LABEL[a.level] || a.level}</span>
|
||||
<span className="sp-alert-title">{a.title}</span>
|
||||
<span className="sp-alert-value">{a.value}</span>
|
||||
<span className="sp-muted sp-small">seit {formatSince(a.since)}</span>
|
||||
<li key={a.id} className="list-row" data-testid="sp-alert">
|
||||
<div className="sp-alert-main">
|
||||
<Badge tone={levelTone(a.level)}>{LEVEL_LABEL[a.level] || a.level}</Badge>
|
||||
<span className="sp-alert-title">{a.title}</span>
|
||||
<span className="sp-alert-value">{a.value}</span>
|
||||
</div>
|
||||
<span className="muted small nowrap">seit {formatSince(a.since)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sp-tiles" data-testid="sp-tiles">
|
||||
<section className="stats sp-tiles" data-testid="sp-tiles">
|
||||
{METRICS.map((x) => (
|
||||
<Tile key={x.id} id={x.id} label={x.label} model={tileModel(x.id, overview?.host?.[x.id])} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="sp-panel" data-testid="sp-chart-panel">
|
||||
<div className="sp-panel-head">
|
||||
<h3>Verlauf</h3>
|
||||
<div className="sp-controls">
|
||||
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
|
||||
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
|
||||
</div>
|
||||
<h2 className="section"><span>Verlauf</span></h2>
|
||||
<div className="sp-controls">
|
||||
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
|
||||
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
|
||||
</div>
|
||||
{chartData && chartData.data[0].length > 0
|
||||
? <Chart data={chartData.data} series={chartData.series} yFmt={m.fmt} />
|
||||
: <div className="sp-muted sp-nodata">{chart ? 'Für diesen Zeitraum liegen keine Messwerte vor.' : 'Lädt …'}</div>}
|
||||
: <div className="muted sp-nodata">{chart ? 'Für diesen Zeitraum liegen keine Messwerte vor.' : 'Lädt …'}</div>}
|
||||
</section>
|
||||
|
||||
<section className="sp-panel" data-testid="sp-projects">
|
||||
<div className="sp-panel-head">
|
||||
<h3>Projekte</h3>
|
||||
{appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>}
|
||||
</div>
|
||||
<h2 className="section">
|
||||
<span>Projekte</span>
|
||||
{appsData?.sizesDay && <span className="muted small">Größen von {appsData.sizesDay}</span>}
|
||||
</h2>
|
||||
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
|
||||
{selected && (
|
||||
<div className="sp-detail" data-testid="sp-detail">
|
||||
<div className="sp-panel-head">
|
||||
<h3>{detail?.name || selected}</h3>
|
||||
<h3 className="sp-detail-title">{detail?.name || selected}</h3>
|
||||
<div className="sp-controls">
|
||||
<Pills items={RANGES} value={detailRange} onChange={changeDetailRange} testid="sp-detail-range" />
|
||||
</div>
|
||||
{detailData ? (
|
||||
@@ -319,13 +326,13 @@ export default function ServerpulsPage() {
|
||||
<div><div className="sp-chart-title">RAM</div><Chart data={detailData.mem.data} series={detailData.mem.series} yFmt={formatBytes} height={200} /></div>
|
||||
<div><div className="sp-chart-title">CPU</div><Chart data={detailData.cpu.data} series={detailData.cpu.series} yFmt={(v) => formatPct(v)} height={200} /></div>
|
||||
</div>
|
||||
) : <div className="sp-muted sp-nodata">Lädt …</div>}
|
||||
) : <div className="muted sp-nodata">Lädt …</div>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sp-panel" data-testid="sp-cleanup">
|
||||
<div className="sp-panel-head"><h3>Aufräumbar</h3></div>
|
||||
<h2 className="section"><span>Aufräumbar</span></h2>
|
||||
<Cleanup data={cleanup} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+41
-43
@@ -1,14 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { dotColor } from '../lib/previewEnv'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchServices } from '../lib/projectKindsApi'
|
||||
import { servicesCards } from '../lib/servicesView'
|
||||
import { ticketPath } from '../lib/ticketRoutes'
|
||||
import { previewStateTone } from '../components/projectTones'
|
||||
import { PageHeader, Card, Button, Badge } from '../components/ui'
|
||||
|
||||
export default function ServicesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -28,59 +26,59 @@ export default function ServicesPage() {
|
||||
|
||||
const cards = useMemo(() => servicesCards(data), [data])
|
||||
|
||||
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
|
||||
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Services" subtitle="Unsere Projekte nach Art" />
|
||||
{error && (
|
||||
<Card>
|
||||
<div className="text-red" style={{ marginBottom: 8 }}>{error}</div>
|
||||
<Button onClick={load}>Erneut versuchen</Button>
|
||||
</Card>
|
||||
<div className="services-stack">
|
||||
<p className="msg err" role="alert">{error}</p>
|
||||
<div className="row-buttons">
|
||||
<Button type="button" onClick={load}>Erneut versuchen</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
|
||||
{cards.map((c) => (
|
||||
<Card key={c.key} title={`${c.label} (${c.count})`}>
|
||||
{c.description && <div className="muted" style={{ marginBottom: 8 }}>{c.description}</div>}
|
||||
{cards.map((c) => (
|
||||
<Card key={c.key} title={`${c.label} (${c.count})`}>
|
||||
<div className="services-stack">
|
||||
{c.description && <p className="muted">{c.description}</p>}
|
||||
{c.features.length > 0 && (
|
||||
<div className="flex gap-2 wrap" style={{ marginBottom: 8 }}>
|
||||
<div className="pv-row">
|
||||
{c.features.map((f) => (
|
||||
<Badge key={f.label}>{f.label}{f.statusLabel ? ` · ${f.statusLabel}` : ''}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="list">
|
||||
{c.projects.map((p) => (
|
||||
<div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}>
|
||||
<div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
{p.previewState && (
|
||||
<span
|
||||
title={`Preview: ${p.previewState}`}
|
||||
style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: dotColor(p.previewState), marginRight: 6 }}
|
||||
>
|
||||
<span style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{`Preview: ${p.previewState}`}</span>
|
||||
</span>
|
||||
)}
|
||||
{p.projectName || p.repoFullName}
|
||||
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
|
||||
</div>
|
||||
<div className="muted">
|
||||
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
||||
{c.projects.length > 0 ? (
|
||||
<div className="list">
|
||||
{c.projects.map((p) => (
|
||||
<div key={p.id} className="list-row">
|
||||
<div className="list-row-text">
|
||||
<div className="cell-title">
|
||||
{p.projectName || p.repoFullName}
|
||||
{p.previewState && <Badge tone={previewStateTone(p.previewState)}>Preview: {p.previewState}</Badge>}
|
||||
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
|
||||
</div>
|
||||
<div className="cell-sub">
|
||||
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
||||
</div>
|
||||
</div>
|
||||
{(p.woid || c.key === 'missing') && (
|
||||
<div className="row-actions">
|
||||
{p.woid && <Link className="link" to={ticketPath(p.woid)}>Ticket #{p.woid}</Link>}
|
||||
{c.key === 'missing' && <Link className="link" to={`/projects/zuordnen/${p.id}`}>Zuordnen</Link>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{p.woid && <Link to={ticketPath(p.woid)}>Ticket #{p.woid}</Link>}
|
||||
{c.key === 'missing' && (
|
||||
<Button variant="primary" onClick={() => navigate(`/projects/zuordnen/${p.id}`)}>Zuordnen</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{c.projects.length === 0 && <div className="muted" style={{ padding: 12 }}>Noch keine Projekte.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted pick-empty">Noch keine Projekte.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Badge, Button } from '../components/ui'
|
||||
import { PageHeader, Card, Badge, Button, EmptyState } from '../components/ui'
|
||||
import { listStorageTasks, markStorageDone } from '../lib/teamApi'
|
||||
import { fmtDate, fmtDateTime } from '../lib/format'
|
||||
import { changeText, failedText, isOverdue, orderedKind, partialText, scopeText, summaryText, taskText } from '../lib/storageFormat'
|
||||
import '../styles/admin-areas.css'
|
||||
import { fmtDateTime } from '../lib/format'
|
||||
import { changeText, failedText, orderedKind, partialText, scopeText, storageDue, summaryText, taskText } from '../lib/storageFormat'
|
||||
|
||||
// E-Mail-Speicher (Kunden -> E-Mail-Speicher): Zusatzspeicher-Aufgaben in konsoleH wie im frueheren Admin-Panel
|
||||
export default function StorageTasksPage() {
|
||||
@@ -39,57 +37,76 @@ export default function StorageTasksPage() {
|
||||
<div className="page">
|
||||
<PageHeader title="E-Mail-Speicher" subtitle="Zusatzspeicher der Abos in konsoleH einrichten und senken" />
|
||||
{error && (
|
||||
<div className="area-error area-inline">
|
||||
{error} <Button size="sm" variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
|
||||
<div className="flex items-center gap-2 wrap mb-2">
|
||||
<span className="msg err" role="alert">{error}</span>
|
||||
<Button onClick={load}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
{!data && !error && <FaSpinner className="spinner" />}
|
||||
{!data && !error && <div className="loading">Wird geladen …</div>}
|
||||
{data && (
|
||||
<>
|
||||
<div className={`area-summary ${summary.warn ? 'warn' : ''}`}>{summary.text}</div>
|
||||
<Card pad={false}>
|
||||
<div className="area-rows">
|
||||
<div className="area-row storage-row head"><span>Fällig</span><span>Kunde</span><span>Aufgabe in konsoleH</span><span>Bestellt</span><span /></div>
|
||||
{data.tasks.length === 0 && <div className="area-row"><span className="area-muted">Keine offenen Speicher-Aufgaben.</span></div>}
|
||||
{data.tasks.map((t) => (
|
||||
<div key={t.id} className="area-row storage-row">
|
||||
<span>
|
||||
{t.kind === 'einrichten'
|
||||
? <><Badge tone="warn">jetzt einrichten</Badge><br /><span className="area-muted">bestellt {fmtDateTime(t.createdAt)}</span></>
|
||||
: <><Badge tone={isOverdue(t) ? 'warn' : 'muted'}>zum Periodenende</Badge><br />{fmtDate(t.dueAt)}</>}
|
||||
</span>
|
||||
<span>
|
||||
<Link to={`/customers/${t.customerId}`}><strong>{t.customerName}</strong></Link>
|
||||
{t.customerCode && <><br /><span className="area-muted">Kd.-Nr. {t.customerCode}</span></>}
|
||||
{t.email && <><br /><span className="area-muted">{t.email}</span></>}
|
||||
<br /><span className="area-muted">{t.productLabel}</span>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{taskText(t)}</strong>
|
||||
<br /><span className="area-muted">{scopeText(t)}</span>
|
||||
<br /><span className="area-muted">{changeText(t)}</span>
|
||||
{t.partial && <><br /><span className="area-danger">{partialText(t)}</span></>}
|
||||
</span>
|
||||
<span>{fmtDateTime(t.createdAt)}<br /><span className="area-muted">{orderedKind(t)}</span></span>
|
||||
<span>
|
||||
<Button size="sm" variant="primary" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
|
||||
{busyId === t.id ? <FaSpinner className="spinner" /> : 'Erledigt'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<p className={summary.warn ? 'msg warn mb-2' : 'muted mb-2'}>{summary.text}</p>
|
||||
{data.tasks.length === 0 ? (
|
||||
<EmptyState title="Keine offenen Speicher-Aufgaben." />
|
||||
) : (
|
||||
<table className="table table-wrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="hide-sm">Fällig</th>
|
||||
<th className="col-name">Kunde</th>
|
||||
<th>Aufgabe in konsoleH</th>
|
||||
<th className="hide-md">Bestellt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.tasks.map((t) => {
|
||||
const due = storageDue(t)
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td className="hide-sm">
|
||||
<Badge tone={due.tone}>{due.label}</Badge>
|
||||
<div className={due.subMuted ? 'cell-sub' : undefined}>{due.sub}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-title"><Link className="link" to={`/customers/${t.customerId}`}>{t.customerName}</Link></div>
|
||||
{t.customerCode && <div className="cell-sub">Kd.-Nr. {t.customerCode}</div>}
|
||||
{t.email && <div className="cell-sub">{t.email}</div>}
|
||||
<div className="cell-sub">{t.productLabel}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-title">{taskText(t)}</div>
|
||||
<div className="cell-sub">{scopeText(t)}</div>
|
||||
<div className="cell-sub">{changeText(t)}</div>
|
||||
{t.partial && <div className="cell-sub"><span className="msg err">{partialText(t)}</span></div>}
|
||||
</td>
|
||||
<td className="hide-md nowrap">
|
||||
{fmtDateTime(t.createdAt)}
|
||||
<div className="cell-sub">{orderedKind(t)}</div>
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<button type="button" className="link" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
|
||||
{busyId === t.id ? 'Speichert …' : 'Erledigt'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{data.failed.length > 0 && (
|
||||
<Card title="Gescheiterte Änderungen (letzte 30 Tage)">
|
||||
<Card className="mt-3" title="Gescheiterte Änderungen (letzte 30 Tage)">
|
||||
{data.failed.map((f) => (
|
||||
<div key={f.id} style={{ marginBottom: 6 }}>
|
||||
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="area-muted">{failedText(f)}</span>
|
||||
<div key={f.id} className="list-row">
|
||||
<div className="list-row-text">
|
||||
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="muted">{failedText(f)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<p className="area-muted" style={{ marginTop: 12 }}>
|
||||
<p className="table-foot">
|
||||
Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/* Bereiche aus dem frueheren Admin-Panel (Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen, Abo-Status).
|
||||
Zeilen als Raster; unter 720 px untereinander, damit nichts waagerecht scrollt. */
|
||||
.area-rows { display: flex; flex-direction: column; container-type: inline-size; }
|
||||
.area-row {
|
||||
display: grid; gap: var(--sp-3); align-items: start; padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border); overflow-wrap: anywhere;
|
||||
}
|
||||
.area-row:last-child { border-bottom: none; }
|
||||
.area-row.head { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.area-row.active { background: var(--accent-soft); }
|
||||
.area-muted { color: var(--text-muted); font-size: 13px; }
|
||||
.area-danger { color: var(--red-fg); font-size: 13px; }
|
||||
.area-warn { color: var(--orange-fg); font-size: 13px; }
|
||||
.area-summary { padding: 12px 16px; border-radius: var(--r-sm); background: var(--surface-2); margin-bottom: var(--sp-3); }
|
||||
.area-summary.warn { background: var(--orange-bg); color: var(--orange-fg); }
|
||||
.area-message { padding: 10px 14px; border-radius: var(--r-sm); background: var(--accent-soft); margin-bottom: var(--sp-3); }
|
||||
.area-error { padding: 10px 14px; border-radius: var(--r-sm); background: var(--red-bg); color: var(--red-fg); margin: var(--sp-2) 0; }
|
||||
.area-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
|
||||
.area-kv dt { color: var(--text-muted); }
|
||||
.area-kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.area-filter { display: inline-flex; gap: 4px; margin-bottom: var(--sp-3); }
|
||||
.area-inline { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
|
||||
.storage-row { grid-template-columns: 150px 220px minmax(0, 1fr) 150px 110px; }
|
||||
.funktionen-row { grid-template-columns: minmax(0, 1.2fr) 110px 200px 110px minmax(0, 1.4fr); }
|
||||
.funktionen-sitekey { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text); user-select: all; }
|
||||
.funktionen-sitekey-copy {
|
||||
display: inline-flex; align-items: center; justify-content: center; width: 25px; height: 25px; flex: 0 0 25px;
|
||||
border: 0; border-radius: 4px; background: transparent; color: var(--text-faint); cursor: pointer;
|
||||
}
|
||||
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
|
||||
.funktionen-sitekey-ok { color: var(--ok); }
|
||||
.funktionen-panel { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); background: var(--surface-2); }
|
||||
.funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; }
|
||||
.funktionen-panel-warnungen { margin: 0; padding-left: 32px; }
|
||||
.funktionen-panel .area-summary { margin-bottom: 0; }
|
||||
.funktionen-panel-zeiten .area-error, .funktionen-panel-zeiten .area-message { margin: 0; }
|
||||
.funktionen-panel-form { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
|
||||
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
|
||||
.funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; }
|
||||
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
|
||||
.funktionen-panel-abschnitt pre {
|
||||
margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); max-width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.area-row.head { display: none; }
|
||||
.funktionen-leer { display: none; }
|
||||
.area-kv { grid-template-columns: minmax(0, 1fr); }
|
||||
.area-kv dt { margin-top: 6px; }
|
||||
}
|
||||
.area-rows a { color: var(--text); text-decoration: none; }
|
||||
.area-rows a:hover { color: var(--accent); text-decoration: underline; }
|
||||
.credit-row { grid-template-columns: 130px minmax(0, 1fr) minmax(0, 1fr) 110px 100px 150px 100px; }
|
||||
.credit-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-5); }
|
||||
.credit-cols h4 { margin: var(--sp-4) 0 var(--sp-2); font-size: 14px; }
|
||||
.credit-files, .credit-checks { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.credit-checks { list-style: none; padding-left: 0; }
|
||||
.credit-history { margin-bottom: 8px; }
|
||||
.credit-manual { display: flex; flex-direction: column; gap: 6px; }
|
||||
.credit-calc { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
|
||||
.credit-calc label, .credit-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); margin-bottom: var(--sp-2); }
|
||||
@media (max-width: 900px) { .credit-cols { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 720px) { .credit-calc { grid-template-columns: minmax(0, 1fr); } }
|
||||
.credit-cols a { color: var(--accent); }
|
||||
.offer-row { grid-template-columns: 130px 200px minmax(0, 1fr) 110px 210px; }
|
||||
/* Nach verfuegbarer Breite stapeln (nicht nach Fensterbreite): mit ausgeklappter Sidebar sind Tablet quer oder ein
|
||||
halbes Laptop-Fenster sonst zu schmal fuer die Spalten */
|
||||
@container (max-width: 960px) {
|
||||
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.area-row.head { display: none; }
|
||||
.funktionen-leer { display: none; }
|
||||
}
|
||||
+106
-28
@@ -1,5 +1,12 @@
|
||||
/* Chat im Ticketsystem (Messenger und Reiter "Chat"). Eingabezeile, Anhaenge und Sprachnachrichten kommen aus
|
||||
chat-composer.css des Kundenportals; hier Farben (--chat-*), Verlauf, Sprechblasen und die Liste. */
|
||||
/* Chat im Ticketsystem (Messenger und Reiter "Chat") im weissen Design.
|
||||
Eingabezeile, Anhaenge und Sprachnachrichten kommen als Baustein aus dem Kundenportal (chat-composer.css, window.ChatComposer).
|
||||
Dessen CSS laedt zur Laufzeit zuletzt, bei gleicher Spezifitaet gewinnt es. Darum ist jede Regel fuer Elemente des Bausteins
|
||||
strikt spezifischer als die Portal-Regel (Praefix .woms-chat, Spezifitaeten in den Kommentaren: Eigen > Fremd) und es gibt kein
|
||||
!important. Einzige Ausnahme mit Absicht: .chat-tray-item, dessen rote Fehlervariante des Portals (gleiche Spezifitaet, spaeter
|
||||
geladen) gewinnen soll. Das Seitenlayout (.chat-page, .chat-side, .chat-list*, .chat-main, .chat-back) hat kein Praefix.
|
||||
Bildansicht und Kamera haengen am body und bleiben dunkel (Medienbetrachter, vom Praefix nicht erreichbar). */
|
||||
|
||||
/* Variablen des Bausteins: weisses Design statt Portal-Lila (P1), dazu Blasen und Spur der Audioleiste (P6) */
|
||||
.woms-chat {
|
||||
--chat-accent: var(--accent);
|
||||
--chat-accent-hover: var(--accent-hover);
|
||||
@@ -10,50 +17,121 @@
|
||||
--chat-surface: var(--bg);
|
||||
--chat-line: var(--line-strong);
|
||||
--chat-muted: var(--text-2);
|
||||
--chat-bubble-in: var(--bg-hover);
|
||||
--chat-bubble-out: var(--grey-bg);
|
||||
--chat-track: rgba(26, 31, 54, 0.2);
|
||||
display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0;
|
||||
}
|
||||
|
||||
/* Verlauf: scrollt in sich, ohne Rand und Hintergrund */
|
||||
.woms-chat .chat-messages {
|
||||
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto;
|
||||
padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg);
|
||||
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Blasen: fremde hellgrau, eigene etwas dunkler und rechts, kein Rand. .chat-bubble 0,2,0 > 0,1,0 */
|
||||
.woms-chat .chat-bubble {
|
||||
align-self: flex-start; max-width: 78%; padding: 8px 12px; border-radius: 14px;
|
||||
background: var(--surface-2); color: var(--text); overflow-wrap: anywhere;
|
||||
align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--radius-lg);
|
||||
background: var(--chat-bubble-in); color: var(--text); overflow-wrap: anywhere;
|
||||
}
|
||||
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--accent-soft); border: 1px solid var(--line); }
|
||||
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 11.5px; color: var(--text-muted); }
|
||||
.woms-chat .chat-notice { font-size: 12px; }
|
||||
/* Meldungen des Eingabebausteins (Stile aus chat-composer.css des Portals): Fehler rot, Info grau, beides lesbar auf Weiss */
|
||||
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--chat-bubble-out); }
|
||||
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 12px; color: var(--text-2); }
|
||||
.woms-chat .chat-notice { font-size: 12px; color: var(--text-2); }
|
||||
|
||||
/* Datei-Chip: weiss mit Ring (die Blase ist selbst --bg-hover). .chat-att-file 0,2,0 > 0,1,0, :hover 0,3,0 > 0,2,0 */
|
||||
.woms-chat .chat-att-file { border: 0; background: var(--bg); box-shadow: var(--ring); }
|
||||
.woms-chat .chat-att-file:hover { background: var(--bg-hover); }
|
||||
|
||||
/* Audioleiste und Fokus. .chat-audio-bar 0,2,0 > 0,1,0; :focus-visible 0,3,0 > 0,2,0 (Portal: weisser Ring) */
|
||||
.woms-chat .chat-audio-bar { background: var(--chat-track); }
|
||||
.woms-chat .chat-att-image:focus-visible,
|
||||
.woms-chat .chat-audio-bar:focus-visible { outline: 2px solid var(--accent); }
|
||||
/* Abspielen in der eigenen Blase: schwarz mit weisser Schrift statt Portal-Weiss. 0,4,0 > 0,3,0 */
|
||||
.woms-chat .chat-bubble.mine .chat-audio-play { background: var(--chat-accent); color: var(--chat-on-accent); }
|
||||
.woms-chat .chat-audio-dl:hover { background: var(--accent-soft); }
|
||||
|
||||
/* Meldungen des Bausteins: Fehler rot (--red-fg), Info grau. .chat-composer-note 0,2,0 > 0,1,0; die Zeitanzeige 0,4,0 > 0,3,0 */
|
||||
.woms-chat .chat-composer-note,
|
||||
.woms-chat .chat-tray-item.is-error .chat-file-size,
|
||||
.woms-chat .chat-audio.is-error .chat-audio-time { color: var(--red-fg); }
|
||||
.woms-chat .chat-composer-note.is-info { color: var(--text-2); }
|
||||
/* Eingabefeld: Grundstil wie im frueheren Admin-Panel (chat-composer.css setzt nur das Layout) */
|
||||
.woms-chat .chat-input-row textarea {
|
||||
resize: none; min-height: 46px; max-height: 130px; padding: 11px 14px; border-radius: 14px;
|
||||
border: 1px solid var(--border); background: var(--surface-2); color: var(--text); font: inherit; line-height: 1.4;
|
||||
}
|
||||
.woms-chat .chat-input-row textarea:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.chat-page { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: var(--sp-4); align-items: start; }
|
||||
.chat-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
|
||||
/* Eingabefeld: Ring statt Rand, Hoehe und Radius wie bisher. 0,2,1 > 0,1,1; :focus 0,3,1 */
|
||||
.woms-chat .chat-input-row textarea {
|
||||
resize: none; min-height: 46px; max-height: 130px; padding: 11px 14px; border: 0; border-radius: 14px;
|
||||
background: var(--bg); color: var(--text); box-shadow: var(--ring); font: inherit; line-height: 1.4;
|
||||
}
|
||||
.woms-chat .chat-input-row textarea:focus { outline: none; box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-ring); }
|
||||
/* "font: inherit" hebt die Portal-Regel gegen das iOS-Zoomen auf: am Handy wieder 16 px */
|
||||
@media (max-width: 640px) {
|
||||
.woms-chat .chat-input-row textarea { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* Icon-Knoepfe (Anhang, Mikrofon) und Senden. .chat-icon-btn 0,2,0 > 0,1,0; :hover:not(:disabled) 0,4,0 > 0,3,0;
|
||||
[aria-expanded] 0,3,0 > 0,2,0; :disabled 0,3,0 > 0,2,0; .chat-send.is-busy:disabled 0,4,0 > 0,3,0.
|
||||
Senden bleibt ueber --chat-accent (P1) schwarz mit weisser Schrift. */
|
||||
.woms-chat .chat-icon-btn { border: 0; background: var(--bg); color: var(--text); box-shadow: var(--ring); }
|
||||
.woms-chat .chat-icon-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.woms-chat .chat-icon-btn[aria-expanded="true"],
|
||||
.woms-chat .chat-icon-btn[aria-expanded="true"]:hover:not(:disabled) { background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.woms-chat .chat-icon-btn:disabled,
|
||||
.woms-chat .chat-send:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.woms-chat .chat-send.is-busy:disabled { opacity: 0.8; cursor: progress; }
|
||||
|
||||
/* Sprachaufnahme: Knoepfe ohne Ring und Hintergrund, Rot ueber --danger. .chat-rec .chat-icon-btn 0,3,0 > 0,2,0; :hover 0,4,0 > 0,3,0.
|
||||
.chat-rec-stop steht hinter der Icon-Knopf-Regel (gleiche Spezifitaet 0,2,0), sonst gewinnt deren Farbe. */
|
||||
.woms-chat .chat-rec { border-color: var(--danger-ring); background: var(--red-bg); }
|
||||
.woms-chat .chat-rec .chat-icon-btn { background: transparent; box-shadow: none; }
|
||||
.woms-chat .chat-rec .chat-icon-btn:hover { background: var(--accent-soft); }
|
||||
.woms-chat .chat-rec-stop { color: var(--danger); }
|
||||
.woms-chat .chat-rec-wave { color: var(--danger); }
|
||||
.woms-chat .chat-rec-dot { background: var(--danger); }
|
||||
|
||||
/* Anhang-Menue: ohne Rand, mit Schatten; Hover und Tastaturfokus --bg-hover, Fokus zusaetzlich mit Umriss (Portal: outline none).
|
||||
.chat-menu 0,2,0 > 0,1,0; .chat-menu button 0,2,1 > 0,1,1; :hover/:focus-visible 0,3,1 > 0,2,1 */
|
||||
.woms-chat .chat-menu { border: 0; border-radius: var(--radius-lg); box-shadow: var(--shadow-pop); }
|
||||
.woms-chat .chat-menu button { border-radius: var(--radius); }
|
||||
.woms-chat .chat-menu button:hover,
|
||||
.woms-chat .chat-menu button:focus-visible { background: var(--bg-hover); }
|
||||
.woms-chat .chat-menu button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
/* Vorschau der Anhaenge vor dem Senden. .chat-tray-item 0,2,0 = Portal-Fehlervariante .chat-tray-item.is-error (0,2,0, spaeter
|
||||
geladen): die rote Variante bleibt absichtlich. */
|
||||
.woms-chat .chat-tray-item { border: 1px solid transparent; background: var(--bg-hover); }
|
||||
|
||||
/* Dateien ablegen. .chat-drop-active::after 0,2,1 > 0,1,1 */
|
||||
.woms-chat.chat-drop-active::after { background: rgba(255, 255, 255, 0.92); color: var(--text); border-color: var(--text-3); }
|
||||
|
||||
/* ------------------------------------------------------------------ Seite: Liste und Verlauf (ohne Praefix) --- */
|
||||
.chat-page { display: grid; grid-template-columns: 320px minmax(0, 1fr); align-items: stretch; }
|
||||
.chat-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; padding-right: var(--sp-4); border-right: 1px solid var(--line); }
|
||||
.chat-main { min-width: 0; padding-left: var(--sp-5); }
|
||||
.chat-list { display: flex; flex-direction: column; max-height: 70vh; overflow-y: auto; }
|
||||
.chat-list-item {
|
||||
display: grid; gap: 2px; padding: 10px 14px; border: none; border-bottom: 1px solid var(--border);
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 2px 8px;
|
||||
width: 100%; padding: 10px 8px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0;
|
||||
background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.chat-list-item:hover { background: var(--surface-2); }
|
||||
.chat-list-item.active { background: var(--accent-soft); }
|
||||
.chat-list-top { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
||||
.chat-list-last { color: var(--text-muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-list-time { color: var(--text-faint); font-size: 11.5px; }
|
||||
.chat-main { min-width: 0; }
|
||||
.chat-back { display: none; }
|
||||
.chat-list-item:first-child { border-top: 1px solid var(--line); }
|
||||
.chat-list-item:hover { background: var(--bg-subtle); }
|
||||
.chat-list-item[aria-current="true"] { background: var(--accent-soft); }
|
||||
.chat-list-top { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
|
||||
.chat-list-top strong { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.chat-list-last { grid-column: 1; min-width: 0; color: var(--text-2); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-list-time { flex: none; color: var(--text-2); font-size: 12px; }
|
||||
.chat-unread { grid-column: 2; color: var(--accent); font-size: 12px; font-weight: 700; }
|
||||
.chat-page .chat-back { display: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 1100px) {
|
||||
.chat-page { grid-template-columns: 260px minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* Handy: eine Spalte, entweder die Liste oder der Verlauf. .chat-page .chat-back 0,2,0 > .btn 0,1,0 */
|
||||
@media (max-width: 860px) {
|
||||
.chat-page { grid-template-columns: minmax(0, 1fr); }
|
||||
.chat-page.has-thread .chat-side { display: none; }
|
||||
.chat-page:not(.has-thread) .chat-main { display: none; }
|
||||
.chat-back { display: inline-flex; }
|
||||
.chat-page .chat-side,
|
||||
.chat-page .chat-main { padding: 0; border: 0; }
|
||||
.chat-page .chat-back { display: inline-flex; margin-bottom: var(--sp-3); }
|
||||
.woms-chat .chat-bubble { max-width: 90%; }
|
||||
}
|
||||
+408
-283
@@ -500,11 +500,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
.ticket-projects { margin-top: 16px; }
|
||||
/* Zeile unter der Projektliste: Projekt waehlen + Zuweisen + Neues Projekt */
|
||||
.ticket-projects-assign { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.ticket-projects-assign .form-control { flex: 1 1 220px; margin: 0; }
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -606,225 +601,187 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
||||
}
|
||||
|
||||
/* --- Rechtliches --- */
|
||||
.legal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); align-items: start; margin-top: var(--sp-4); }
|
||||
.legal-grid > .legal-error { grid-column: 1 / -1; }
|
||||
.legal-doc-summary { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; cursor: pointer; }
|
||||
.legal-toggle { margin-left: auto; font-size: 13px; }
|
||||
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; color: var(--accent); }
|
||||
.legal-history { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
|
||||
.legal-version { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 8px 10px; font-size: 14px; }
|
||||
.legal-version-current { border-color: var(--ok); background: rgba(16, 185, 129, 0.08); }
|
||||
.legal-version-draft { border-style: dashed; }
|
||||
.legal-version-superseded { color: var(--text-muted); }
|
||||
.legal-version-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.legal-note { white-space: pre-wrap; margin: 4px 0; color: var(--text-muted); font-size: 13px; }
|
||||
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; }
|
||||
.legal-files a { color: var(--accent); }
|
||||
.legal-error { background: var(--danger); color: #fff; padding: 8px 12px; border-radius: var(--r-sm); margin: 8px 0; }
|
||||
.legal-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
|
||||
.legal-doc-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%; padding: 12px 0; text-align: left; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.legal-toggle { margin-left: auto; font-size: 13px; color: var(--text-2); }
|
||||
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; }
|
||||
.legal-history { list-style: none; margin: 0; padding: 0; }
|
||||
.legal-version { padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.legal-version.is-superseded { color: var(--text-2); }
|
||||
.legal-version-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.legal-version-head .link { margin-left: auto; }
|
||||
.legal-note { white-space: pre-wrap; margin: 4px 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.legal-files .link { font-weight: 400; }
|
||||
.legal-files .legal-dl { color: var(--text-2); }
|
||||
.legal-msg { margin: 0 0 12px; }
|
||||
.legal-doc h2.section .link { white-space: nowrap; } /* "Neue Version" bleibt am Handy eine Zeile */
|
||||
.legal-file { padding: 2px 1px; font-size: 13px; color: var(--text-2); } /* Platz für den Ring des Knopfs, das Dateifeld schneidet ihn sonst ab */
|
||||
.legal-file::file-selector-button { height: 28px; padding: 0 10px; margin-right: 10px; border: 0; border-radius: var(--radius); background: var(--bg); box-shadow: var(--ring); font: inherit; font-weight: 500; color: var(--text); cursor: pointer; }
|
||||
.legal-contracts { margin-top: var(--sp-2); }
|
||||
.legal-contracts tbody tr { cursor: pointer; }
|
||||
.legal-tone-red td { background: rgba(239, 68, 68, 0.10); }
|
||||
.legal-tone-red .legal-deadline { color: var(--danger); font-weight: 700; }
|
||||
.legal-tone-orange td { background: rgba(245, 158, 11, 0.10); }
|
||||
.legal-tone-orange .legal-deadline { color: var(--orange-fg); font-weight: 600; }
|
||||
.legal-tone-missing .legal-deadline { color: var(--text-faint); font-style: italic; }
|
||||
.legal-tone-grey td { color: var(--text-faint); }
|
||||
.legal-detail td { background: var(--surface-2); cursor: default; }
|
||||
.legal-actions { margin-top: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 700px) {
|
||||
.legal-grid { grid-template-columns: 1fr; }
|
||||
.legal-form-row { grid-template-columns: 1fr; }
|
||||
.legal-contracts { display: block; overflow-x: auto; }
|
||||
.legal-contracts .cell-title .link { text-align: left; } /* Knopf zentriert sonst lange Anbieternamen */
|
||||
.legal-contracts tr.row:has(+ .legal-detail) td { border-bottom: 0; } /* offene Zeile und Detail bilden einen Block, die Haarlinie steht darunter */
|
||||
.legal-detail td { padding: 0 0 16px; }
|
||||
.legal-facts { display: none; margin-bottom: 8px; }
|
||||
.legal-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 8px; }
|
||||
.legal-cancel { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.legal-cancel .input { width: 160px; }
|
||||
@media (max-width: 860px) {
|
||||
.legal-facts { display: grid; }
|
||||
}
|
||||
|
||||
/* ===== FINANZEN (P7) ===== */
|
||||
/* --- Finanzen --- */
|
||||
button.link-danger { background: none; border: 0; padding: 0; font: inherit; font-weight: 500; cursor: pointer; }
|
||||
|
||||
/* Allgemein: Seitengeruest, Betraege, Meldungen, Spalten. Rot gilt fuer einen Betrag, dessen Text mit dem Minuszeichen beginnt. */
|
||||
.fin-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.fin-stack { display: flex; flex-direction: column; gap: var(--sp-5); }
|
||||
.fin-stack .kpi-grid { margin-bottom: 0; }
|
||||
.fin-spin { animation: spin 1s linear infinite; }
|
||||
.fin-hint { margin: 0; font-size: 13px; color: var(--text-muted); }
|
||||
.fin-hint-warn { color: var(--warn); }
|
||||
.fin-flag { color: var(--danger); font-weight: 700; }
|
||||
.fin-link {
|
||||
background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
|
||||
color: var(--accent); display: inline-flex; align-items: center; gap: 6px;
|
||||
text-decoration: underline; text-underline-offset: 2px;
|
||||
}
|
||||
.fin-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
/* Block 0: Statuszeile */
|
||||
.fin-status-wrap { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.fin-status {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-3);
|
||||
padding: 10px 14px; font-size: 13px; color: var(--text-muted);
|
||||
background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
}
|
||||
.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px var(--sp-4); min-width: 0; }
|
||||
.fin-status-item { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.fin-status-warn { color: var(--warn); font-weight: 600; }
|
||||
|
||||
/* Block 1: Liquiditaetskopf */
|
||||
.fin-head .kpi-value { font-variant-numeric: tabular-nums; }
|
||||
.fin-breakdown { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; gap: 4px; color: var(--text-muted); }
|
||||
.fin-breakdown li { display: flex; justify-content: space-between; gap: var(--sp-3); }
|
||||
.fin-breakdown .fin-amount { color: var(--text); }
|
||||
.fin-breakdown-total { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: 700; color: var(--text); }
|
||||
.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 6px 10px; align-items: baseline; font-size: 14px; font-weight: 600; }
|
||||
.fin-low { display: contents; }
|
||||
.fin-low-label { color: var(--text-muted); }
|
||||
.fin-low .fin-amount { font-size: 20px; font-weight: 800; }
|
||||
.fin-low-when { color: var(--text-muted); font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Block 2: Zu erledigen */
|
||||
.fin-todo-card { overflow: hidden; }
|
||||
.fin-todos { list-style: none; margin: 0; padding: 0; }
|
||||
.fin-todo {
|
||||
display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start;
|
||||
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border); border-left: 3px solid var(--border);
|
||||
}
|
||||
.fin-todo:last-child { border-bottom: none; }
|
||||
.fin-todo-hoch { border-left-color: var(--danger); }
|
||||
.fin-todo-mittel { border-left-color: var(--warn); }
|
||||
.fin-todo .badge { justify-self: start; }
|
||||
.fin-todo-title { font-weight: 700; }
|
||||
.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.fin-todo .fin-hint { margin-top: 6px; }
|
||||
.fin-todos-more { padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
|
||||
@media (max-width: 720px) {
|
||||
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
|
||||
.fin-todo .ui-btn { justify-self: start; }
|
||||
}
|
||||
|
||||
/* Bloecke 3-5: Vorschau, Steuer, Abos */
|
||||
.fin-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.fin-neg { color: var(--danger); }
|
||||
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); align-items: start; }
|
||||
.fin-neg, .fin-flag { color: var(--red-fg); }
|
||||
.fin-flag { font-weight: 600; }
|
||||
.fin-stack > * + * { margin-top: 36px; }
|
||||
.fin-top { display: flex; flex-direction: column; gap: 20px; }
|
||||
.fin-block { scroll-margin-top: 16px; }
|
||||
.fin-body { container-type: inline-size; }
|
||||
.fin-table-wrap { overflow-x: auto; }
|
||||
.fin-notice, .fin-page-notice { margin: 0 0 8px; }
|
||||
.fin-page-notice { margin-bottom: 16px; }
|
||||
.fin-load-error { display: grid; gap: 12px; justify-items: start; padding: 16px 0; }
|
||||
/* Die einspaltige Reihe bekommt ihren Zeilenabstand vom Abschnittsabstand (36 px). Die Regel steht mit hoeherer Spezifitaet hier, weil KOMPAT */
|
||||
/* .fin-cols > section + section auf 0 setzt; ab 1100 px stehen die Spalten nebeneinander und beginnen auf gleicher Hoehe. */
|
||||
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0 56px; align-items: start; }
|
||||
.fin-stack .fin-cols > section + section { margin-top: 36px; }
|
||||
@media (min-width: 1100px) {
|
||||
.fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
|
||||
.fin-cols-half { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fin-stack .fin-cols > section + section { margin-top: 0; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.fin-block { scroll-margin-top: 68px; } /* unter der 52 px hohen Mobil-Leiste 16 px Luft */
|
||||
}
|
||||
|
||||
/* Block 3: Vorschau (Tabelle; in einer schmalen Karte wird jede Zeile zweizeilig) */
|
||||
.fin-forecast-card { container-type: inline-size; overflow: hidden; }
|
||||
.fin-table-wrap { overflow-x: auto; }
|
||||
.fin-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; }
|
||||
/* Block 0: Statuszeile (ohne Kasten) */
|
||||
.fin-status-wrap > .fin-notice { margin: 8px 0 0; }
|
||||
.fin-status { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 13px; color: var(--text-2); }
|
||||
.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px 24px; min-width: 0; }
|
||||
.fin-status-item { min-width: 0; overflow-wrap: anywhere; }
|
||||
.fin-status-warn { color: var(--orange-fg); font-weight: 600; }
|
||||
|
||||
/* Block 1: Liquiditaetskopf. Die Doppelklasse schlaegt die Handy-Regel von .stats (3 Spalten wuerden die breite dritte Kennzahl abschneiden). */
|
||||
.stats.fin-head { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 24px 48px; margin: 0; }
|
||||
.stats.fin-head .sub { font-size: 13px; line-height: 20px; color: var(--text-2); }
|
||||
.fin-breakdown { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 4px; }
|
||||
.fin-breakdown li { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.fin-breakdown .fin-amount { color: var(--text); }
|
||||
.fin-breakdown-total { border-top: 1px solid var(--line); padding-top: 4px; font-weight: 600; color: var(--text); }
|
||||
.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 4px 10px; align-items: baseline; }
|
||||
.fin-low { display: contents; }
|
||||
.fin-low-label, .fin-low-when { font-size: 13px; line-height: 20px; font-weight: 400; letter-spacing: 0; color: var(--text-2); }
|
||||
.fin-low .fin-amount { font-size: 18px; line-height: 26px; font-weight: 600; }
|
||||
|
||||
/* Block 2: Zu erledigen */
|
||||
.fin-todos { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.fin-todo { display: grid; grid-template-columns: 80px minmax(0, 1fr) auto; gap: 12px 16px; align-items: start; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.fin-todo .badge { justify-self: start; }
|
||||
.fin-todo .link { justify-self: end; white-space: nowrap; }
|
||||
.fin-todo-title { font-weight: 600; }
|
||||
.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.fin-todo .msg { margin-top: 6px; }
|
||||
@media (max-width: 860px) {
|
||||
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||
.fin-todo .link { justify-self: start; }
|
||||
}
|
||||
|
||||
/* Bloecke 3-5: Vorschau, Steuer, Abos */
|
||||
|
||||
/* Block 3: Vorschau. Die Tabelle liegt in .fin-body (Container); schmal wird jede Zeile ein Raster. Zellen: Abstaende wie .table, nur oben und unten 9 px. */
|
||||
.fin-forecast th, .fin-forecast td { vertical-align: baseline; }
|
||||
.fin-forecast td { padding-block: 9px; }
|
||||
.fin-forecast th.fin-amount { text-align: right; }
|
||||
.fin-forecast th:first-child, .fin-forecast td:first-child { padding-left: var(--sp-4); }
|
||||
.fin-forecast th:last-child, .fin-forecast td:last-child { padding-right: var(--sp-4); }
|
||||
.fin-forecast tbody tr:last-child td { border-bottom: none; }
|
||||
.fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.fin-forecast-label { overflow-wrap: anywhere; }
|
||||
.fin-forecast-foot { display: grid; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
|
||||
.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
|
||||
.fin-forecast-foot { display: grid; gap: 6px; margin-top: 12px; }
|
||||
@container (max-width: 560px) {
|
||||
.fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-forecast, .fin-forecast tbody { display: block; }
|
||||
.fin-forecast tr {
|
||||
display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
|
||||
grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet";
|
||||
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; }
|
||||
.fin-forecast tbody tr:last-child { border-bottom: none; }
|
||||
.fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||
.fin-forecast tbody tr:hover td { background: transparent; }
|
||||
.fin-forecast-date { grid-area: date; }
|
||||
.fin-forecast-kind { grid-area: kind; }
|
||||
.fin-forecast-amount { grid-area: amount; font-weight: 700; }
|
||||
.fin-forecast-amount { grid-area: amount; font-weight: 600; }
|
||||
.fin-forecast-label { grid-area: label; }
|
||||
.fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--sp-2); font-size: 13px;
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; font-size: 13px;
|
||||
}
|
||||
.fin-forecast-sicher { grid-area: sicher; }
|
||||
.fin-forecast-erwartet { grid-area: erwartet; }
|
||||
.fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); }
|
||||
.fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); }
|
||||
.fin-forecast td.fin-forecast-sicher { padding-right: 12px; }
|
||||
.fin-forecast td.fin-forecast-erwartet { padding-left: 12px; }
|
||||
.fin-saldo { margin-left: auto; }
|
||||
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-size: 12px; font-weight: 500; white-space: nowrap; }
|
||||
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-2); font-size: 12px; font-weight: 500; white-space: nowrap; }
|
||||
}
|
||||
|
||||
/* Block 4: Steuer */
|
||||
.fin-facts { display: grid; gap: var(--sp-4); }
|
||||
.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||
.fin-fact:last-child { padding-bottom: 0; border-bottom: none; }
|
||||
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); }
|
||||
.fin-fact-label { color: var(--text-muted); font-size: 13px; }
|
||||
.fin-fact-big { font-size: 24px; font-weight: 800; }
|
||||
.fin-fact-strong { font-size: 16px; font-weight: 700; }
|
||||
.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.fin-fact-unknown { color: var(--warn); }
|
||||
.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
/* Block 4: Steuer (Liste mit Haarlinien; die Fussnote steht nach dem letzten Posten) */
|
||||
.fin-facts { display: grid; gap: 16px; }
|
||||
.fin-fact { display: grid; gap: 3px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
||||
.fin-fact:last-of-type { padding-bottom: 0; border-bottom: 0; }
|
||||
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.fin-fact-label { color: var(--text-2); font-size: 13px; }
|
||||
.fin-fact-big { font-size: 22px; line-height: 30px; font-weight: 600; }
|
||||
.fin-fact-strong { font-size: 16px; font-weight: 600; }
|
||||
.fin-fact-value { font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.fin-fact-unknown { color: var(--orange-fg); }
|
||||
.fin-fact-sub { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||
.fin-fact-sub-strong { color: var(--text); }
|
||||
|
||||
/* Block 5: Abos */
|
||||
.fin-abo-card { overflow: hidden; }
|
||||
.fin-stats { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px var(--sp-3); align-items: baseline; padding: var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||
.fin-stat-value { font-size: 20px; font-weight: 800; }
|
||||
.fin-stat-label { color: var(--text-muted); }
|
||||
.fin-stat-label.fin-flag { color: var(--danger); }
|
||||
.fin-stat-label svg { vertical-align: -2px; }
|
||||
.fin-abos { list-style: none; margin: 0; padding: 0; }
|
||||
.fin-abo {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-3); align-items: baseline;
|
||||
padding: 11px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.fin-abo:last-child { border-bottom: none; }
|
||||
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
|
||||
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
|
||||
/* Block 5: Abos (Kennzahlen: .stats.fin-abo-stats ohne eigene Regel) */
|
||||
.fin-abos { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.fin-abo { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.fin-abo-customer { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; }
|
||||
|
||||
/* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
|
||||
.fin-block { scroll-margin-top: var(--sp-4); }
|
||||
.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }
|
||||
.fin-toolbar { padding: var(--sp-3) var(--sp-4); }
|
||||
.fin-toolbar .seg { flex-wrap: wrap; max-width: 100%; }
|
||||
.fin-notice { padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
||||
.fin-load-error { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-4); }
|
||||
.fin-page-notice { margin-bottom: var(--sp-4); }
|
||||
.fin-ages {
|
||||
list-style: none; margin: 0; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3);
|
||||
}
|
||||
.fin-age { display: grid; gap: 2px; min-width: 0; }
|
||||
.fin-age-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.fin-age-count { font-size: 13px; color: var(--text-muted); }
|
||||
.fin-age-sum { text-align: left; font-size: 18px; font-weight: 800; }
|
||||
.fin-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
|
||||
.fin-pager-info { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
/* Block 6 und 7: Rechnungen und Buchungen. Beide Tabellen liegen in .fin-body (Container); schmal wird jede Zeile ein Raster. */
|
||||
.fin-block .seg { flex-wrap: wrap; max-width: 100%; }
|
||||
.stats.fin-ages { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 32px; margin: 0 0 16px; }
|
||||
.fin-ages .stat .v { font-size: 18px; line-height: 26px; }
|
||||
.fin-pager { justify-content: center; flex-wrap: wrap; gap: 16px; }
|
||||
.fin-pager-info { font-variant-numeric: tabular-nums; }
|
||||
.fin-block button.link:disabled, .fin-block button.link-danger:disabled { cursor: not-allowed; }
|
||||
|
||||
.fin-invoices th, .fin-invoices td, .fin-tx th, .fin-tx td { padding: 9px 10px; vertical-align: top; }
|
||||
.fin-invoices th.fin-amount, .fin-tx th.fin-amount { text-align: right; }
|
||||
.fin-invoices th:first-child, .fin-invoices td:first-child, .fin-tx th:first-child, .fin-tx td:first-child { padding-left: var(--sp-4); }
|
||||
.fin-invoices th:last-child, .fin-invoices td:last-child, .fin-tx th:last-child, .fin-tx td:last-child { padding-right: var(--sp-4); }
|
||||
.fin-invoices tbody tr:last-child td, .fin-tx tbody tr:last-child td { border-bottom: none; }
|
||||
.fin-inv-number { font-weight: 700; white-space: nowrap; }
|
||||
.fin-inv-customer { overflow-wrap: anywhere; }
|
||||
.fin-inv-number { font-weight: 500; white-space: nowrap; }
|
||||
.fin-inv-customer, .fin-tx-counterparty { overflow-wrap: anywhere; }
|
||||
.fin-tx-counterparty { font-weight: 500; }
|
||||
.fin-inv-created, .fin-inv-due, .fin-tx-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.fin-inv-statusbox { display: grid; gap: 3px; justify-items: start; }
|
||||
.fin-inv-note { font-size: 12px; }
|
||||
.fin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fin-tx-counterparty { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fin-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||
.fin-tx-remittance {
|
||||
font-size: 13px; overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.fin-tx-amount { font-weight: 700; }
|
||||
.fin-tx-amount { font-weight: 600; }
|
||||
.fin-tx-badges { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.fin-tx-assign .badge { white-space: normal; }
|
||||
.fin-tx-assign .badge { white-space: normal; height: auto; padding-block: 2px; }
|
||||
.fin-tx-suggestion, .fin-tx-note { font-size: 12px; margin-top: 4px; overflow-wrap: anywhere; }
|
||||
.fin-tx-action { white-space: nowrap; text-align: right; }
|
||||
@media (max-width: 860px) {
|
||||
.fin-block .seg button { padding: 0 9px; }
|
||||
}
|
||||
@container (max-width: 860px) {
|
||||
.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fin-invoices thead, .fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-invoices, .fin-invoices tbody, .fin-tx, .fin-tx tbody { display: block; }
|
||||
.stats.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fin-invoices thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-invoices, .fin-invoices tbody { display: block; }
|
||||
.fin-invoices tr {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px var(--sp-4); align-items: baseline;
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 16px; align-items: baseline;
|
||||
grid-template-areas: "number status" "customer customer" "created due" "total remaining" "actions actions";
|
||||
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.fin-invoices tbody tr:last-child { border-bottom: none; }
|
||||
.fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child, .fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||
.fin-invoices tbody tr:hover td, .fin-tx tbody tr:hover td { background: transparent; }
|
||||
.fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||
.fin-inv-number { grid-area: number; }
|
||||
.fin-inv-status { grid-area: status; }
|
||||
.fin-inv-statusbox { justify-items: end; text-align: right; }
|
||||
@@ -834,16 +791,18 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
||||
.fin-inv-total { grid-area: total; }
|
||||
.fin-inv-remaining { grid-area: remaining; }
|
||||
.fin-inv-actions { grid-area: actions; padding-top: 4px; }
|
||||
.fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 var(--sp-2); font-size: 13px; }
|
||||
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-weight: 500; }
|
||||
.fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 8px; font-size: 13px; }
|
||||
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-2); font-weight: 500; }
|
||||
}
|
||||
@container (max-width: 640px) {
|
||||
.fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-tx, .fin-tx tbody { display: block; }
|
||||
.fin-tx tr {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline;
|
||||
grid-template-areas: "date amount" "what what" "assign action";
|
||||
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.fin-tx tbody tr:last-child { border-bottom: none; }
|
||||
.fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||
.fin-tx-date { grid-area: date; }
|
||||
.fin-tx-amount { grid-area: amount; }
|
||||
.fin-tx-what { grid-area: what; }
|
||||
@@ -851,113 +810,84 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
||||
.fin-tx-action { grid-area: action; align-self: start; }
|
||||
}
|
||||
|
||||
/* Dialoge: Rechnung schreiben, Buchung zuordnen. Overlay, Box und Schliessknopf kommen aus KOMPAT (.overlay:has(> .overlay-content), */
|
||||
/* .overlay-content, .overlay-close); hier nur, was dort fehlt. */
|
||||
.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
|
||||
.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.fin-dialog { max-width: 760px; }
|
||||
.fin-dialog:focus { outline: none; }
|
||||
.fin-dialog-error {
|
||||
margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
|
||||
border: 1px solid var(--danger); background: var(--red-bg); color: var(--red-fg); overflow-wrap: anywhere;
|
||||
}
|
||||
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: var(--orange-fg); overflow-wrap: anywhere; }
|
||||
.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
|
||||
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px var(--sp-3); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.fin-address-warn-dialog { margin: 6px 0 0; color: var(--orange-fg); }
|
||||
.fin-address-warn-page { margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); color: var(--warn); }
|
||||
.fin-address-warn .fin-link { font-size: 13px; }
|
||||
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
|
||||
.fin-items legend { padding: 0; }
|
||||
.fin-item {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) 90px 130px 120px 38px; gap: 8px; align-items: end; margin-bottom: 8px;
|
||||
}
|
||||
/* Dialoge: Rechnung schreiben, Buchung zuordnen. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
|
||||
.fin-fixed-customer { overflow-wrap: anywhere; }
|
||||
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; }
|
||||
.fin-address-warn-page { margin: 0 0 16px; }
|
||||
.fin-address-warn-dialog { margin: 0; }
|
||||
/* Rechnung schreiben: Positionen, Summe, Zahlungsziel und Notiz */
|
||||
.fin-items { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.fin-items legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
|
||||
.fin-items .msg { margin-top: 8px; overflow-wrap: anywhere; }
|
||||
.fin-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
|
||||
.fin-item + .fin-item { margin-top: 8px; }
|
||||
.fin-item-field { min-width: 0; }
|
||||
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); }
|
||||
.fin-item ~ .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-item .form-control.fin-amount { text-align: right; }
|
||||
.fin-item-total { display: block; padding: 10px 0; font-weight: 600; }
|
||||
.fin-icon-btn {
|
||||
width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted);
|
||||
}
|
||||
.fin-icon-btn:hover:not(:disabled) { color: var(--red-fg); border-color: var(--red-fg); }
|
||||
.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
.fin-total { display: flex; align-items: baseline; gap: var(--sp-4); font-size: 18px; font-weight: 700; }
|
||||
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-4); }
|
||||
.fin-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-3); margin-top: var(--sp-4); }
|
||||
.fin-tx-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
|
||||
.fin-tx-facts > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
|
||||
.fin-tx-facts dt { color: var(--text-muted); font-size: 13px; }
|
||||
.fin-tx-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.fin-tx-facts .fin-tx-facts-amount { text-align: left; font-weight: 700; }
|
||||
.fin-assigned { margin: 0 0 var(--sp-2); }
|
||||
.fin-choices { border: 0; padding: 0; margin: 0 0 var(--sp-4); display: grid; gap: 8px; min-width: 0; }
|
||||
.fin-choices legend { padding: 0; margin-bottom: 4px; }
|
||||
.fin-choice {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; cursor: pointer;
|
||||
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
|
||||
}
|
||||
.fin-choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.fin-choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.fin-choice input { margin-top: 4px; accent-color: var(--accent); }
|
||||
.fin-choice-off { opacity: 0.65; cursor: not-allowed; }
|
||||
.fin-item .input { width: 100%; }
|
||||
.fin-item .fin-amount { text-align: right; }
|
||||
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-2); }
|
||||
.fin-item + .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.fin-item-sum { text-align: right; }
|
||||
.fin-item-total { display: block; line-height: 32px; font-weight: 600; }
|
||||
.fin-item-remove { display: flex; align-items: center; height: 32px; }
|
||||
.fin-item-remove button:disabled { cursor: not-allowed; }
|
||||
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
||||
.fin-total { display: flex; align-items: baseline; gap: 16px; font-size: 18px; font-weight: 600; }
|
||||
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 12px; }
|
||||
/* Zuordnen (Dialog): Fuss, Fakten, Auswahlzeilen, Bestaetigung. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
|
||||
.fin-foot { flex: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.kv .fin-tx-facts-amount { text-align: left; font-weight: 600; }
|
||||
.fin-assigned { margin: 0; }
|
||||
.fin-choices { border: 0; padding: 0; margin: 0; display: grid; min-width: 0; }
|
||||
.fin-choices legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
|
||||
.fin-choice { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.fin-choice input { flex: none; margin: 3px 0 0; }
|
||||
.fin-choice-off { opacity: 0.55; cursor: not-allowed; }
|
||||
.fin-choice-text { display: grid; gap: 2px; min-width: 0; }
|
||||
.fin-choice-label { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fin-choice-detail { font-size: 13px; overflow-wrap: anywhere; }
|
||||
.fin-confirm { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); background: rgba(245, 158, 11, 0.1); }
|
||||
.fin-confirm:focus { outline: 2px solid var(--warn); outline-offset: 2px; }
|
||||
.fin-confirm p { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) {
|
||||
.fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) 38px; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
|
||||
.fin-choice-label { color: var(--text); overflow-wrap: anywhere; }
|
||||
.fin-choice:has(input:checked) .fin-choice-label { font-weight: 600; }
|
||||
.fin-choice-detail { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||
.fin-choice-detail.warn { color: var(--orange-fg); }
|
||||
.fin-confirm { font-weight: 600; color: var(--orange-fg); overflow-wrap: anywhere; }
|
||||
.fin-confirm p { margin: 0; }
|
||||
@media (max-width: 860px) {
|
||||
.fin-foot .btn { flex: 1 1 auto; }
|
||||
.fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
|
||||
.fin-item + .fin-item { margin-top: 16px; }
|
||||
.fin-item-desc { grid-area: desc; }
|
||||
.fin-item-qty { grid-area: qty; }
|
||||
.fin-item-price { grid-area: price; }
|
||||
.fin-item-sum { grid-area: sum; text-align: right; }
|
||||
.fin-item-sum { grid-area: sum; }
|
||||
.fin-item-remove { grid-area: remove; }
|
||||
.fin-item ~ .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
|
||||
.fin-item + .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
|
||||
.fin-dialog-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.fin-tx-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
|
||||
.fin-toolbar .seg button { padding: 6px 9px; }
|
||||
}
|
||||
|
||||
/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */
|
||||
.fin-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.fin-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--sp-4); }
|
||||
.fin-fieldset { display: grid; gap: 16px; border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.fin-fieldset .field-row { gap: 16px; }
|
||||
.fin-form-field { min-width: 0; }
|
||||
.fin-form-hint { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.fin-field-error { font-size: 13px; font-weight: 600; color: var(--danger); overflow-wrap: anywhere; }
|
||||
/* KOMPAT setzt .page .field auf 12 px Abstand unten; im Raster bestimmt allein der gap den Abstand */
|
||||
.page .fin-fieldset .field, .page .fin-fixed-form .field { margin-bottom: 0; }
|
||||
.fin-input-amount { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.fin-counter { margin: -6px 0 var(--sp-3); text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.fin-counter-over { color: var(--danger); font-weight: 700; }
|
||||
.fin-form-notice { margin: 0 0 var(--sp-3); }
|
||||
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
.fin-bank-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-3); }
|
||||
.fin-bank-facts > div { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
|
||||
.fin-bank-facts dt { color: var(--text-muted); font-size: 13px; }
|
||||
.fin-bank-facts dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.fin-fixed-form { margin-bottom: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
|
||||
.fin-fixed-form-title { margin: 0 0 var(--sp-3); font-size: 15px; font-weight: 700; }
|
||||
.fin-fixed-list { list-style: none; margin: 0; padding: 0; }
|
||||
.fin-fixed {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
|
||||
padding: var(--sp-3) 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.fin-fixed:last-child { border-bottom: none; }
|
||||
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-muted); }
|
||||
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px var(--sp-2); min-width: 0; }
|
||||
.fin-fixed-name { font-weight: 700; overflow-wrap: anywhere; }
|
||||
.fin-fixed-amount { font-weight: 700; }
|
||||
.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px var(--sp-4); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.fin-counter { margin-top: -8px; text-align: right; font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
.fin-counter-over { color: var(--danger); font-weight: 600; }
|
||||
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 16px; }
|
||||
.fin-kv { margin: 0 0 16px; }
|
||||
.fin-result { font-size: 16px; line-height: 24px; font-weight: 600; }
|
||||
.fin-fixed-form { padding-bottom: 24px; margin-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||
.fin-form-title { margin: 0 0 16px; font-size: 14px; line-height: 20px; font-weight: 600; }
|
||||
.fin-fixed-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.fin-fixed-form ~ .fin-fixed-list { border-top: 0; } /* das offene Formular schliesst mit seiner eigenen Linie ab */
|
||||
.fin-fixed { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-2); }
|
||||
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; }
|
||||
.fin-fixed-name { font-weight: 500; overflow-wrap: anywhere; }
|
||||
.fin-fixed-amount { font-weight: 600; }
|
||||
.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.fin-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
|
||||
@media (max-width: 640px) {
|
||||
.fin-form-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
}
|
||||
.fin-fixed-actions button:disabled, .fin-notice button:disabled { cursor: not-allowed; }
|
||||
|
||||
/* ===== ENDE FINANZEN ===== */
|
||||
|
||||
/* ===== Tickets (P2) ===== */
|
||||
/* Arbeitsblatt-Zeilen, Zeilenlinks, Nachladen und Unterstuetzungs-Zeile. Steht direkt vor KOMPAT: gewinnt ueber die Bausteinregeln */
|
||||
@@ -1043,6 +973,210 @@ button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .
|
||||
}
|
||||
/* ===== ENDE Planboard (P3) ===== */
|
||||
|
||||
/* ===== Kunden (P4) ===== */
|
||||
/* Die Prototypregel .kv .input gilt nur fuer einzeilige Felder */
|
||||
.kv .textarea { width: 100%; max-width: 360px; }
|
||||
/* Wertetabellen mit Feldern: Beschriftung und Feld teilen die Grundlinie (Textfeld: erste Zeile) */
|
||||
.kunde-kv { align-items: baseline; }
|
||||
/* Mehrzeiliges Feld: die Beschriftung steht auf Hoehe der ersten Zeile (Innenabstand des Textfelds), nicht an seiner Unterkante */
|
||||
@media (min-width: 861px) {
|
||||
.kunde-kv .kunde-kv-oben { align-self: start; padding-top: 8px; }
|
||||
}
|
||||
/* Uebersicht: unter der Wertetabelle kommen das Kaestchen "Wichtiger Kunde" und die Knopfzeile */
|
||||
.kv + .check { margin-top: 16px; }
|
||||
.kv ~ .toolbar { margin-top: 16px; }
|
||||
.kunde-hinweis { margin-bottom: 12px; }
|
||||
/* Abzeichen unter dem Namen: Abstand zum Folgenden, oben buendig (sonst waechst die Zeile um 1 px) */
|
||||
.kunde-sub .badge { margin-right: 8px; vertical-align: top; }
|
||||
/* Reiter "Abo & Angebote": Abstand zwischen Abo-Status und Angeboten (der Umschlag ist ein div, .page section + section greift dort nicht) */
|
||||
.kunde-abo-angebote { margin-top: 36px; }
|
||||
/* Reiter "Funktionen": eine Tabelle, je Projekt eine Gruppenzeile; Bestaetigung und Panels sind Zusatzzeilen direkt unter der Funktionszeile */
|
||||
.funktionen-group th { text-align: left; font-size: 14px; font-weight: 600; color: var(--text); padding-top: 20px; overflow-wrap: anywhere; white-space: normal; }
|
||||
.funktionen-group .link { font-size: 13px; font-weight: 400; margin-left: 8px; }
|
||||
.funktionen-sitekey { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 4px; }
|
||||
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; user-select: all; }
|
||||
.funktionen-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||
.funktionen-confirm td { padding-top: 0; }
|
||||
.funktionen-note { width: 100%; max-width: 260px; }
|
||||
/* Zeilen einer Gruppe (Funktionszeile, Bestaetigung, Panels): nur die letzte behaelt die Haarlinie */
|
||||
.funktionen-offen td { border-bottom: 0; }
|
||||
.funktionen-extra td { padding-top: 0; }
|
||||
.funktionen-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; background: var(--bg-subtle); border-radius: var(--radius); }
|
||||
.funktionen-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
|
||||
.funktionen-panel-warnungen { margin: 0; padding-left: 18px; }
|
||||
.funktionen-panel-abschnitt { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); min-width: 0; }
|
||||
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
|
||||
.funktionen-panel-abschnitt pre {
|
||||
margin: 0; padding: 10px 12px; border-top: 1px solid var(--line); max-width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
|
||||
}
|
||||
/* Oeffnungszeiten live (Panel unter der Funktionszeile): Team-Formular und Ersatz-HTML */
|
||||
.funktionen-panel-form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
|
||||
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
|
||||
.funktionen-zustimmung { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
|
||||
.funktionen-zustimmung input { flex: none; margin-top: 3px; width: 15px; height: 15px; accent-color: var(--accent); }
|
||||
.funktionen-vorlage summary { cursor: pointer; }
|
||||
.funktionen-vorlage p { margin: 6px 0; white-space: pre-wrap; }
|
||||
.funktionen-msg { margin-top: 12px; }
|
||||
/* Webshop-Anfrage je Projekt unter der Tabelle: ein Block je Eintrag, dazwischen eine Haarlinie */
|
||||
.funktionen-shop { display: flex; flex-direction: column; gap: 8px; margin-top: 24px; }
|
||||
.funktionen-shop + .funktionen-shop { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
.funktionen-shop dd { white-space: pre-wrap; }
|
||||
.funktionen-begruendung { width: 100%; max-width: 560px; }
|
||||
@media (min-width: 861px) {
|
||||
.funktionen-shop .kv { grid-template-columns: max-content minmax(0, 1fr); }
|
||||
}
|
||||
/* Handy: Kopf nur fuer Screenreader, jede Funktionszeile als zwei Spalten (Name | Status, Ticket, Aktionen untereinander) */
|
||||
@media (max-width: 860px) {
|
||||
.funktionen-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.funktionen-table, .funktionen-table tbody, .funktionen-table tr, .funktionen-table td, .funktionen-table th { display: block; }
|
||||
.funktionen-table .funktionen-zeile { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 4px 12px; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.funktionen-table .funktionen-zeile.funktionen-offen { border-bottom: 0; }
|
||||
.funktionen-zeile > td { grid-column: 2; min-width: 0; padding: 0; border-bottom: 0; }
|
||||
.funktionen-zeile > td:first-child { grid-column: 1; grid-row: 1; }
|
||||
.funktionen-zeile > td:empty, .funktionen-zeile > .funktionen-ohne-ticket { display: none; }
|
||||
.funktionen-confirm td { padding-bottom: 12px; }
|
||||
.funktionen-extra td { padding-bottom: 12px; }
|
||||
}
|
||||
/* ===== ENDE Kunden (P4) ===== */
|
||||
|
||||
/* ===== Projekte (P5) ===== */
|
||||
/* Projektliste als Tabelle, Dialog-Inhalte, Preview-Block, Projektzeile am Ticket und beim Kunden, Zuordnen-Assistent. Steht direkt vor */
|
||||
/* KOMPAT: gewinnt ueber die Bausteinregeln oberhalb durch die Reihenfolge, nie durch eine Erzwingung. */
|
||||
button.link { text-align: left; } /* Reset und Gewicht von button.link stehen im Block Tickets (P2) */
|
||||
|
||||
/* ---------- Projektliste (Tabelle) ---------- */
|
||||
.projects-table td:first-child { min-width: 140px; }
|
||||
.projects-table th, .projects-table td { padding-right: 8px; }
|
||||
.projects-table th:last-child, .projects-table td:last-child { padding-right: 0; }
|
||||
.projects-table .cell-title .link, .projects-table .cell-sub { overflow-wrap: anywhere; }
|
||||
.projects-table td.num { min-width: 64px; overflow-wrap: anywhere; } /* Ticket ohne Eintrag in der Ticketliste zeigt die lange $id: sie bricht um, die Tabelle waechst nicht ueber den Rahmen; 64 px halten 6-stellige Nummern in einer Zeile */
|
||||
.projects-table .select { width: clamp(104px, 10vw, 180px); max-width: 100%; }
|
||||
.projects-table td.cell-address code { display: block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.projects-table td.cell-updated { min-width: 70px; }
|
||||
.projects-table tr.is-archived td { opacity: .72; }
|
||||
.projects-table tr.is-archived .faint { color: var(--text-2); } /* mit opacity .72 fiele das helle Grau von .faint unter 3:1 (Muster: .table tr.row:hover .faint) */
|
||||
.row-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; font-size: 13px; } /* Links (a) und Textknoepfe (button) auf einer Zeile; ohne baseline sitzt ein button.link-danger in einer hoeheren Zeile mittig statt oben */
|
||||
.row-actions .link { white-space: nowrap; }
|
||||
.projects-table .row-actions { justify-content: flex-end; }
|
||||
/* Unter 860 px steht jede Zeile als zweispaltiges Raster: Projekt ueber beide Spalten, Kunde und Status nebeneinander, Aktionen darunter. */
|
||||
/* Rollen (table, row, cell) stehen im JSX, damit die Tabellensemantik beim Stapeln erhalten bleibt. */
|
||||
@media (max-width: 860px) {
|
||||
.projects-table, .projects-table tbody, .projects-table td { display: block; }
|
||||
.projects-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } /* nur fuer Screenreader */
|
||||
.projects-table tbody tr { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; padding: 14px 0; border-bottom: 1px solid var(--line); }
|
||||
.projects-table td { padding: 0; border: 0; }
|
||||
.projects-table td:first-child, .projects-table td.cell-actions { grid-column: 1 / -1; }
|
||||
.projects-table td[data-label]::before { content: attr(data-label); display: block; font-size: 12px; color: var(--text-2); }
|
||||
.projects-table .select { width: 100%; }
|
||||
.projects-table .row-actions { justify-content: flex-start; }
|
||||
.projects-table tr.row:hover td, .projects-table tr.row:hover td:first-child, .projects-table tr.row:hover td:last-child { background: none; box-shadow: none; }
|
||||
/* (0,2,1) schlaegt .hide-sm (0,1,0) und .projects-table td { display: block } (0,1,1) ueber die Spezifitaet, ohne Erzwingung */
|
||||
.projects-table td.hide-sm, .projects-table td.hide-md { display: none; }
|
||||
}
|
||||
.badge.wrap { white-space: normal; height: auto; min-height: 20px; padding-top: 2px; padding-bottom: 2px; overflow-wrap: anywhere; }
|
||||
|
||||
/* ---------- Dialog-Inhalte ---------- */
|
||||
.dialog-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
/* Textvorlage der Analyse-Zustimmung (details im Dialog): Zeiger wie vorher, Abstaende statt Aussenabstaende im JSX, die Vorlage enthaelt lange Adressen ohne Leerzeichen */
|
||||
.dialog-body details > summary { cursor: pointer; }
|
||||
.dialog-body details[open] > summary { margin-bottom: 8px; }
|
||||
.dialog-body details > .prose { margin-bottom: 8px; overflow-wrap: anywhere; }
|
||||
|
||||
/* ---------- Preview-Block (im Projekt-Dialog, am Ticket und beim Kunden) ---------- */
|
||||
.pv-block { font-size: 13px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.pv-row, .pv-add, .pv-var { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.pv-var { padding-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||
.pv-mode { width: 200px; max-width: 100%; }
|
||||
.pv-title { font-size: 13px; font-weight: 600; }
|
||||
.pv-block .msg { overflow-wrap: anywhere; } /* Fehlertexte des Runners enthalten lange Pfade ohne Leerzeichen */
|
||||
.pv-log { background: var(--bg-subtle); max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all; padding: 8px; border-radius: var(--radius); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 16px; }
|
||||
.pv-var code, .pv-var .input, .pv-add .input { flex: 1 1 140px; min-width: 0; }
|
||||
.pv-var code { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---------- Projektzeile am Ticket und beim Kunden ---------- */
|
||||
/* Spalte mit Abstand statt Aussenabstaenden an den Kindern: ein offener Dialog (fest, ausserhalb des Flusses) bekaeme sonst einen Versatz */
|
||||
.proj-item { padding: 16px 0; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 8px; }
|
||||
.proj-item > button.link { align-self: flex-start; } /* sonst waere der ganze Streifen klickbar */
|
||||
.proj-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; }
|
||||
.proj-main { min-width: 0; overflow-wrap: anywhere; }
|
||||
.proj-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--text-2); }
|
||||
.ticket-projects-assign { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.ticket-projects-assign .select { flex: 1 1 220px; min-width: 0; }
|
||||
.ticket-projects-assign .input { flex: 1 1 160px; min-width: 0; }
|
||||
|
||||
/* ---------- Zuordnen-Assistent ---------- */
|
||||
.seg.wrap { flex-wrap: wrap; }
|
||||
.row-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.check.top { align-items: flex-start; }
|
||||
.pick-list { max-height: 360px; overflow-y: auto; }
|
||||
.pick-empty { padding: 12px 0; }
|
||||
button.list-row { width: 100%; text-align: left; background: none; border: 0; border-bottom: 1px solid var(--line); font: inherit; color: inherit; cursor: pointer; }
|
||||
button.list-row:hover, button.list-row[aria-pressed="true"] { background: var(--bg-hover); }
|
||||
button.list-row .cell-sub { display: block; }
|
||||
button.list-row:focus-visible { outline-offset: -2px; } /* in .pick-list (Scroll-Container) wuerde der Ring sonst abgeschnitten */
|
||||
.summary { list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||
/* Inhalt eines Schritts als Spalte mit Abstand (der Reset setzt jeden Aussenabstand auf 0); Segment und Haken bleiben so breit wie ihr Inhalt */
|
||||
.assign-step { display: flex; flex-direction: column; gap: 12px; }
|
||||
.assign-step > .seg, .assign-step > .check { align-self: flex-start; }
|
||||
.page .assign-step .field { margin-bottom: 0; } /* KOMPAT gibt .page .field 12 px unten; hier steht der Abstand der Spalte (P10 loescht beide) */
|
||||
button.list-row:hover .faint, button.list-row[aria-pressed="true"] .faint { color: var(--text-2); } /* .faint hat auf --bg-hover nur 2,9:1 (Muster: .table tr.row:hover .faint) */
|
||||
/* ---------- Services ---------- */
|
||||
/* Beschreibung, Funktions-Badges und Zeilen einer Art als Spalte mit Abstand (der Reset setzt jeden Aussenabstand auf 0); auch Fehlermeldung plus Knopf */
|
||||
.services-stack { display: flex; flex-direction: column; gap: 12px; }
|
||||
.services-stack > .pick-empty { padding: 0; } /* der Abstand kommt aus gap */
|
||||
/* ===== ENDE Projekte (P5) ===== */
|
||||
|
||||
/* ===== Kunden-Unterseiten (P6) ===== */
|
||||
/* Kleine Ergaenzungen fuer Gutschriften, Angebote und E-Mail-Speicher. Steht direkt vor KOMPAT: gewinnt bei gleicher Spezifitaet */
|
||||
/* ueber die Bausteinregeln oberhalb durch die Reihenfolge, nie durch eine Erzwingung. */
|
||||
|
||||
/* table-wrap gilt nur fuer die drei P6-Tabellen: lange Namen, Domains und Anbieter brechen statt die Seite zu verbreitern */
|
||||
.table.table-wrap td { overflow-wrap: anywhere; }
|
||||
/* Die aufgeklappte Gutschrift liegt auf --bg-subtle; die Zeilen sind nicht klickbar, daher kein tr.row */
|
||||
.table tr[aria-current="true"] td { background: var(--bg-subtle); }
|
||||
/* Aktionszeile in der letzten Zelle (button.link / .link-danger); ersetzt das gestrichene .mt-1. Nur fuer das div: P5 nutzt */
|
||||
/* td.cell-actions als Zellenmarker der Projektliste (display bleibt table-cell), die Klasse darf dort kein Flex setzen. */
|
||||
div.cell-actions { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; }
|
||||
/* Dialogfuss bricht um, wenn Knoepfe und Hinweis nicht in eine Zeile passen (Handy) */
|
||||
.dialog-foot { flex-wrap: wrap; }
|
||||
/* Eine lange Knopfbeschriftung (Wortlaut bleibt, z. B. "Angebot anlegen und aufs laufende Abo anwenden") bricht im Dialogfuss um, */
|
||||
/* statt ihn zu verbreitern: .btn ist nowrap, Umbrechen der Zeile allein reicht nicht. Einzeilig bleibt der Knopf 32 px hoch. */
|
||||
.dialog-foot .btn { white-space: normal; height: auto; min-height: 32px; padding-top: 6px; padding-bottom: 6px; text-align: center; }
|
||||
/* deckt auch verschachtelte Felder, P7 prueft die Regel */
|
||||
.autocomplete .input { width: 100%; }
|
||||
/* Formularbloecke in einer Karte (Gutschrift: Berechnung) stehen mit 16 px Abstand untereinander, wie im Dialog (.dialog-body). */
|
||||
/* Der Abstand kommt vom Container, nicht vom Altabstand .page .field (KOMPAT, faellt in P10); die zweite Regel nimmt ihn dort weg. */
|
||||
.form-stack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.page .form-stack > .field { margin-bottom: 0; }
|
||||
/* Angebote: die Statusspalte (Badge, Stripe-Links, Mail-Zeile, Aktionen) wird nicht schmaler als ganze Woerter; ohne Mindestbreite */
|
||||
/* gibt ihr die Tabelle (td bricht ueberall) fast nichts ab und "Abschluss" oder "Zurueckziehen" brechen mitten im Wort. */
|
||||
.table-wrap th.col-status { min-width: 7rem; }
|
||||
@media (min-width: 861px) { .table-wrap th.col-status { min-width: 12rem; } }
|
||||
/* Kundenspalte (E-Mail-Speicher, Angebote, Gutschriften): wird nicht schmaler als ein ganzer Name ("Physiotherapie"); die Aufgaben-, */
|
||||
/* Angebots- oder Statusspalte ist lang und nimmt sonst fast die ganze Breite, und td bricht ueberall: am Handy stuende "Physiothe-rapie" in 65 px. */
|
||||
.table-wrap th.col-name { min-width: 7.5rem; }
|
||||
/* Angebotsspalte (Angebote): braucht ebenfalls eine Mindestbreite, sonst bricht der lange Angebots- und Notiztext am Handy mitten im Wort */
|
||||
/* ("Ansprechpartne-r", "Geschaeftsfuehru-ng" in 103 px). Bis 600 px passen 7,5rem (Kunde) + 8rem (Angebot) + 6rem (Status) in 358 px; */
|
||||
/* die Kundenspalte braucht 7,5rem fuer "Anlagentechnik" (14 Zeichen), nur die Angebotstabelle hat col-status. */
|
||||
.table-wrap th.col-offer { min-width: 8rem; }
|
||||
@media (max-width: 600px) { .table-wrap th.col-status { min-width: 6rem; } }
|
||||
/* ===== ENDE Kunden-Unterseiten (P6) ===== */
|
||||
|
||||
/* --- Rechtliches und Admin (P8) --- */
|
||||
/* Zugangsdaten (CopyableCredential), Screenreader-Text und Drehanimation. Die Regeln lagen in AdminPage.css und gelten fuer */
|
||||
/* alle Seiten (Admin-Tabelle, Kundenseite, Login). Steht direkt vor KOMPAT. */
|
||||
.credential { display: flex; max-width: 300px; align-items: center; gap: 8px; margin: 2px 0; }
|
||||
.credential .link { flex: 0 0 auto; font-size: 12px; white-space: nowrap; }
|
||||
.credential-value { min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 12px; }
|
||||
.credential-value.is-masked { color: var(--text-2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.08em; }
|
||||
.credential-empty { color: var(--text-3); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
/* Kein Aufrufer mehr nach P8 (der Spinner der Admin-Seite war der einzige); P10 loescht die Regel, wenn rg -w spin src --glob '!*.css' nichts findet */
|
||||
.spin { animation: spin 0.8s linear infinite; }
|
||||
|
||||
/* ===== KOMPAT (bis P10) ===== */
|
||||
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
|
||||
/* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */
|
||||
@@ -1124,13 +1258,6 @@ select.form-control {
|
||||
.text-grey { color: var(--text-2); }
|
||||
.text-red { color: var(--red-fg); }
|
||||
|
||||
/* Zaehler und Initiale der Altlisten: .side-count bis P6 (Chat-Liste), .side-logo bis P4 (Kundenliste) */
|
||||
.side-count { margin-left: auto; font-size: 12px; font-weight: 500; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
.side-logo {
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px;
|
||||
border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Neutral: Karte, Raster */
|
||||
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
|
||||
.card-header { padding: 0 0 8px; background: none; border-bottom: 1px solid var(--line); color: var(--text); font-weight: 600; }
|
||||
@@ -1147,19 +1274,17 @@ div.row { display: flex; flex-wrap: wrap; margin: 0 -8px; }
|
||||
.page .page { margin: 0; padding: 0; max-width: none; }
|
||||
|
||||
/* Links in Bestandsklassen, die bisher --accent als Farbe nutzten: feiner Unterstrich */
|
||||
.fin-link, .legal-files a, .credit-cols a, .area-rows a {
|
||||
.fin-link {
|
||||
text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; text-decoration-thickness: 1px;
|
||||
}
|
||||
.fin-link:hover, .legal-files a:hover, .credit-cols a:hover, .area-rows a:hover { text-decoration-color: currentColor; }
|
||||
.fin-link:hover { text-decoration-color: currentColor; }
|
||||
|
||||
/* Abschnitte (Card = <section>) als Spalten nebeneinander: der Abstand .page section + section gilt nur fuer untereinander stehende Abschnitte */
|
||||
.legal-grid > section + section, .fin-cols > section + section,
|
||||
.fin-cols > section + section,
|
||||
.page div[style*="display: grid"] > section + section { margin-top: 0; }
|
||||
|
||||
/* Altformulare mit <Field> (Kunde, Projekt zuweisen, Neuer Lead): Zeilenabstand wie frueher; neue Container bestimmen ihren Abstand selbst */
|
||||
.page .field { margin-bottom: 12px; }
|
||||
.dialog-body .field, .field-row .field, .field-row-3 .field, .kv .field { margin-bottom: 0; }
|
||||
|
||||
/* Kennzahlen nebeneinander (LiquidityHead), bis P7 sie ersetzt */
|
||||
.kpi-grid { display: flex; flex-wrap: wrap; gap: 48px; }
|
||||
/* ===== ENDE KOMPAT ===== */
|
||||
+45
-59
@@ -1,68 +1,54 @@
|
||||
/* Serverpuls (Admin) */
|
||||
.sp-page { display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
.sp-error { background: var(--red-bg); border: 1px solid var(--danger-ring); color: var(--red-fg); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-md); }
|
||||
.sp-muted { color: var(--text-muted); }
|
||||
.sp-small { font-size: 12px; }
|
||||
/* Serverpuls (P9): Kopf, Warnungen, Kacheln, Umschalter, Panels, Projekttabelle */
|
||||
.sp-page .msg { margin: 0 0 16px; }
|
||||
|
||||
.sp-head { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); }
|
||||
.sp-level-medium { border-color: var(--orange-fg); }
|
||||
.sp-level-critical { border-color: var(--danger); }
|
||||
.sp-head-top { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
||||
.sp-headline { font-size: 18px; }
|
||||
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-muted); }
|
||||
.sp-age-stale { color: var(--orange-fg); background: var(--orange-bg); padding: 2px 10px; border-radius: var(--r-pill); }
|
||||
.sp-alerts { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.sp-alert { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: baseline; padding: 8px 12px; border-radius: var(--r-sm); background: var(--surface-2); border-left: 3px solid var(--text-faint); }
|
||||
.sp-alert-critical { border-left-color: var(--danger); }
|
||||
.sp-alert-medium { border-left-color: var(--warn); }
|
||||
.sp-alert-level { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); min-width: 56px; }
|
||||
.sp-alert-title { font-weight: 600; }
|
||||
.sp-alert-value { color: var(--text-muted); }
|
||||
.sp-head-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.sp-headline { font-size: 16px; line-height: 24px; font-weight: 600; }
|
||||
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-2); }
|
||||
.sp-age-stale { color: var(--orange-fg); font-weight: 500; }
|
||||
.sp-alerts { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.sp-alert-main { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; }
|
||||
.sp-alert-title { font-weight: 500; overflow-wrap: anywhere; }
|
||||
.sp-alert-value { color: var(--text-2); }
|
||||
|
||||
.sp-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--sp-3); }
|
||||
@media (max-width: 1100px) { .sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 700px) { .sp-tiles { grid-template-columns: 1fr; } .sp-age { margin-left: 0; } }
|
||||
.sp-tile { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.sp-tile-label { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
|
||||
.sp-tile-value { font-size: 26px; font-weight: 800; }
|
||||
.sp-tile-sub { font-size: 12px; color: var(--text-muted); min-height: 16px; }
|
||||
.sp-tile-delta { font-size: 12px; margin-top: 2px; }
|
||||
.stats.sp-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px 32px; }
|
||||
.sp-tile { min-width: 0; }
|
||||
.sp-tile .sub { min-height: 16px; }
|
||||
.sp-spark { display: block; max-width: 100%; margin-top: 6px; }
|
||||
.sp-tile-delta { font-size: 12px; line-height: 16px; margin-top: 4px; }
|
||||
.sp-delta-up { color: var(--red-fg); }
|
||||
.sp-delta-down { color: var(--green-fg); }
|
||||
.sp-delta-flat { color: var(--text-2); }
|
||||
|
||||
.sp-delta-up { color: var(--danger); }
|
||||
.sp-delta-down { color: var(--ok); }
|
||||
.sp-delta-flat { color: var(--text-muted); }
|
||||
|
||||
.sp-panel { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); min-width: 0; }
|
||||
.sp-panel-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
|
||||
.sp-panel-head h3 { margin: 0; font-size: 16px; }
|
||||
.sp-controls { display: flex; gap: var(--sp-3); flex-wrap: wrap; }
|
||||
.sp-pills { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.sp-pill { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); border-radius: var(--r-pill); padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.sp-pill:hover { color: var(--text); }
|
||||
.sp-pill.active { background: var(--accent-soft); border-color: var(--border-accent); color: var(--accent); font-weight: 600; }
|
||||
.sp-panel { min-width: 0; }
|
||||
.sp-controls { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
|
||||
.sp-controls .seg { flex-wrap: wrap; }
|
||||
.sp-chart { width: 100%; min-height: 200px; }
|
||||
.sp-chart .u-legend { color: var(--text-muted); font-size: 12px; }
|
||||
.sp-nodata { padding: var(--sp-6, 32px) 0; text-align: center; }
|
||||
.sp-chart .u-legend { font-size: 13px; color: var(--text-2); }
|
||||
.sp-nodata { padding: 40px 0; text-align: center; color: var(--text-2); }
|
||||
|
||||
.sp-search { width: 100%; max-width: 320px; margin-bottom: var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); color: var(--text); border-radius: var(--r-sm); padding: 8px 12px; font: inherit; }
|
||||
.sp-table-wrap { overflow-x: auto; }
|
||||
.sp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.sp-table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.sp-sortable { cursor: pointer; user-select: none; }
|
||||
.sp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.sp-row { cursor: pointer; }
|
||||
.sp-row:hover, .sp-row-active { background: var(--surface-2); }
|
||||
.sp-name { font-weight: 600; }
|
||||
.sp-empty { text-align: center; color: var(--text-muted); }
|
||||
.sp-sort { display: block; width: 100%; text-align: inherit; white-space: nowrap; }
|
||||
.sp-page .table td.num { white-space: nowrap; }
|
||||
.sp-name { overflow-wrap: anywhere; }
|
||||
.sp-name .link { text-align: left; overflow-wrap: anywhere; }
|
||||
.sp-page .table tr.sp-row-active td { background: var(--bg-subtle); }
|
||||
.sp-page .table tr.sp-row-active td:first-child { box-shadow: -12px 0 0 var(--bg-subtle); }
|
||||
.sp-page .table tr.sp-row-active td:last-child { box-shadow: 12px 0 0 var(--bg-subtle); }
|
||||
.sp-empty { text-align: center; color: var(--text-2); }
|
||||
.sp-delta { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.sp-detail { margin-top: var(--sp-4); padding-top: var(--sp-4); border-top: 1px solid var(--border); }
|
||||
.sp-detail-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); }
|
||||
.sp-chart-title { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.sp-detail { margin-top: 28px; }
|
||||
.sp-detail-title { margin: 0 0 8px; font-size: 15px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.sp-detail-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: 24px; }
|
||||
.sp-chart-title { font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
|
||||
|
||||
.sp-cleanup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--sp-3); }
|
||||
.sp-cleanup-grid > div { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sp-cleanup-grid strong { font-size: 20px; }
|
||||
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
|
||||
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.sp-cleanup-list { list-style: none; margin: 12px 0; padding: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stats.sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.stats.sp-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 24px; }
|
||||
.sp-age { margin-left: 0; }
|
||||
.sp-page .sp-cleanup-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const css = readFileSync(path.join(root, 'src/styles/chat.css'), 'utf8')
|
||||
const thread = readFileSync(path.join(root, 'src/components/chat/ChatThread.jsx'), 'utf8')
|
||||
|
||||
// Quelltext ohne Kommentare: der Kopfkommentar darf "!important" und "720 px" nennen, ohne die Prüfungen zu reißen
|
||||
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
// Alle Selektoren (ohne @-Regeln), Kommas gesplittet
|
||||
const selectors = [...rules.matchAll(/([^{}]+)\{/g)]
|
||||
.map((m) => m[1].trim())
|
||||
.filter((s) => s && !s.startsWith('@'))
|
||||
.flatMap((s) => s.split(',').map((x) => x.trim()))
|
||||
|
||||
// Fremde Klassen des Portal-Bausteins (chat-composer.css): jede Regel darüber braucht das Präfix .woms-chat
|
||||
const FREMD = /\.chat-(bubble|meta|messages|notice|input-row|icon-btn|send|menu|tray|rec|audio|att|file|composer|drop-active)\b/
|
||||
|
||||
test('chat.css: jede Regel für Klassen des Portal-Bausteins beginnt mit .woms-chat', () => {
|
||||
const bad = selectors.filter((s) => FREMD.test(s) && !s.startsWith('.woms-chat'))
|
||||
assert.deepEqual(bad, [])
|
||||
})
|
||||
|
||||
test('chat.css: kein !important (Spezifität statt Gewalt, Portal-CSS lädt zuletzt)', () => {
|
||||
assert.doesNotMatch(rules, /!important/)
|
||||
})
|
||||
|
||||
test('chat.css: Umschaltung Liste/Verlauf bei 860 px, kein 720-px-Wert mehr', () => {
|
||||
assert.match(rules, /@media \(max-width: 860px\)/)
|
||||
assert.doesNotMatch(rules, /720px/)
|
||||
})
|
||||
|
||||
test('ChatThread: die drei Container des Portal-Bausteins bleiben unverändert', () => {
|
||||
assert.ok(thread.includes('className="woms-chat" ref={zoneRef}'))
|
||||
assert.ok(thread.includes('className="chat-messages" ref={boxRef}'))
|
||||
assert.ok(thread.includes('<div ref={composerRef} />'))
|
||||
})
|
||||
|
||||
test('chat.css: .chat-page .chat-back ist außerhalb der Handyansicht verborgen (schlägt .btn aus global.css)', () => {
|
||||
assert.match(rules, /\.chat-page \.chat-back\s*\{[^}]*display:\s*none/)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// P6: Zeilen der Gutschriften (Liste und Detail) als reine Helfer, damit die Seiten keine Inline-Ausdruecke brauchen
|
||||
const { creditStatus, creditActionLabel, creditStatusNote, checkGlyph, checkTone } = await loadModule('src/lib/creditFormat.js')
|
||||
const NB = ' '
|
||||
|
||||
test('creditStatus: Tonnamen und Beschriftungen sind festgelegt', () => {
|
||||
assert.deepEqual(creditStatus('offen'), { tone: 'warn', label: 'Zu prüfen' })
|
||||
assert.deepEqual(creditStatus('rueckfrage'), { tone: 'muted', label: 'Rückfrage beim Kunden' })
|
||||
assert.deepEqual(creditStatus('genehmigt'), { tone: 'ok', label: 'Genehmigt' })
|
||||
assert.deepEqual(creditStatus('abgelehnt'), { tone: 'danger', label: 'Abgelehnt' })
|
||||
assert.deepEqual(creditStatus('neu'), { tone: 'muted', label: 'neu' })
|
||||
})
|
||||
|
||||
test('creditActionLabel: offene und rueckgefragte Antraege werden geprueft, alle anderen angesehen', () => {
|
||||
assert.equal(creditActionLabel('offen'), 'Prüfen')
|
||||
assert.equal(creditActionLabel('rueckfrage'), 'Prüfen')
|
||||
for (const s of ['genehmigt', 'abgelehnt', 'neu', undefined]) assert.equal(creditActionLabel(s), 'Ansehen')
|
||||
})
|
||||
|
||||
test('creditStatusNote: nur genehmigte Antraege tragen Betrag und Buchungsstand', () => {
|
||||
assert.equal(creditStatusNote({ status: 'genehmigt', amountCents: 1250, appliedAt: '2026-10-01T10:00:00Z' }), `12,50${NB}€`)
|
||||
assert.equal(creditStatusNote({ status: 'genehmigt', amountCents: 1250 }), `12,50${NB}€ · nicht gebucht`)
|
||||
assert.equal(creditStatusNote({ status: 'offen', amountCents: 1250 }), '')
|
||||
})
|
||||
|
||||
test('checkGlyph und checkTone: Zeichen und Ton der automatischen Pruefung', () => {
|
||||
assert.equal(checkGlyph('ok'), '✓')
|
||||
assert.equal(checkTone('ok'), 'ok')
|
||||
assert.equal(checkGlyph('warn'), '!')
|
||||
assert.equal(checkTone('warn'), 'warn')
|
||||
assert.equal(checkGlyph('info'), 'i')
|
||||
assert.equal(checkTone('info'), 'info')
|
||||
assert.equal(checkGlyph(undefined), 'i')
|
||||
assert.equal(checkTone(undefined), 'muted')
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { CUSTOMER_TABS, customerStage, isArchived, filterCustomers, stageCounts, customerBadges, stageLabel, customerEyebrow, customerTitle } =
|
||||
await loadModule('src/lib/customerList.js')
|
||||
|
||||
const A = { $id: 'a', name: 'Alpha GmbH', code: 'K-1', location: 'Kaiserslautern', email: 'info@alpha.de', contactName: 'Anna Alt', customerStatus: 'customer' }
|
||||
const B = { $id: 'b', name: 'Beta', customerStatus: 'lead' }
|
||||
const C = { $id: 'c', name: 'Gamma', customerStatus: 'lost' }
|
||||
const D = { $id: 'd', name: 'Delta' } // Bestandsdaten ohne Status gelten als fester Kunde
|
||||
const E = { $id: 'e', name: 'Epsilon', customerStatus: 'lead', archived: true, importantCustomer: true }
|
||||
const F = { $id: 'f', name: 'Zeta', customerStatus: 'customer', archived: true }
|
||||
const ALL = [A, B, C, D, E, F]
|
||||
const ids = (list) => list.map((c) => c.$id)
|
||||
|
||||
test('CUSTOMER_TABS, customerStage, isArchived', () => {
|
||||
assert.deepEqual(CUSTOMER_TABS, [
|
||||
{ id: 'customer', label: 'Feste Kunden' }, { id: 'lead', label: 'Leads' },
|
||||
{ id: 'archived', label: 'Archiviert' }, { id: 'all', label: 'Alle' },
|
||||
])
|
||||
assert.equal(customerStage({ customerStatus: 'LEAD' }), 'lead')
|
||||
assert.equal(customerStage({ customerStatus: 'Lost' }), 'lost')
|
||||
for (const c of [{}, { customerStatus: '' }, { customerStatus: 'sonstwas' }, null, undefined]) assert.equal(customerStage(c), 'customer')
|
||||
assert.equal(isArchived({ archived: true }), true)
|
||||
for (const c of [{}, { archived: false }, null, undefined]) assert.equal(isArchived(c), false)
|
||||
})
|
||||
|
||||
test('filterCustomers: jeder Reiter', () => {
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'customer' })), ['a', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'lead' })), ['b'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'archived' })), ['e', 'f'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'all' })), ['a', 'b', 'c', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL)), ['a', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'x' })), [])
|
||||
assert.deepEqual(filterCustomers(undefined, { stage: 'all' }), [])
|
||||
})
|
||||
|
||||
test('filterCustomers: Suche in Name, Code, Standort, E-Mail und Ansprechpartner', () => {
|
||||
const find = (search, stage = 'all') => ids(filterCustomers(ALL, { stage, search }))
|
||||
for (const s of ['ALPHA', 'k-1', 'kaisers', 'info@alpha', 'anna']) assert.deepEqual(find(s), ['a'], s)
|
||||
assert.deepEqual(find('xyz'), [])
|
||||
assert.deepEqual(find('eta', 'lead'), ['b'])
|
||||
assert.deepEqual(find(''), ['a', 'b', 'c', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers([{ $id: 'n' }], { stage: 'all', search: 'undefined' })), [])
|
||||
})
|
||||
|
||||
test('stageCounts: dieselbe Filterfunktion wie die Liste', () => {
|
||||
assert.deepEqual(stageCounts(ALL), { customer: 2, lead: 1, archived: 2, all: 4 })
|
||||
assert.deepEqual(stageCounts(ALL, 'eta'), { customer: 0, lead: 1, archived: 1, all: 1 })
|
||||
assert.deepEqual(stageCounts([]), { customer: 0, lead: 0, archived: 0, all: 0 })
|
||||
})
|
||||
|
||||
test('customerBadges: Reihenfolge, Töne, Option stage', () => {
|
||||
assert.deepEqual(customerBadges(E), [
|
||||
{ key: 'important', label: 'Wichtig', tone: 'warn' },
|
||||
{ key: 'lead', label: 'Lead', tone: 'muted' },
|
||||
{ key: 'archived', label: 'Archiviert', tone: 'plain' },
|
||||
])
|
||||
assert.deepEqual(customerBadges(C), [{ key: 'lost', label: 'Abgesagt', tone: 'plain' }])
|
||||
for (const c of [A, D, null]) assert.deepEqual(customerBadges(c), [])
|
||||
assert.deepEqual(customerBadges(E, { stage: false }).map((b) => b.key), ['important', 'archived'])
|
||||
})
|
||||
|
||||
test('stageLabel, customerEyebrow, customerTitle', () => {
|
||||
assert.deepEqual(['customer', 'lead', 'lost'].map(stageLabel), ['Fester Kunde', 'Lead', 'Abgesagt'])
|
||||
assert.equal(customerEyebrow(A), 'K-1 · Fester Kunde')
|
||||
assert.equal(customerEyebrow(B), 'Lead')
|
||||
assert.equal(customerEyebrow({ code: ' ', customerStatus: 'lost' }), 'Abgesagt')
|
||||
assert.equal(customerTitle(A), 'Alpha GmbH')
|
||||
assert.equal(customerTitle({ email: 'x@y.de' }), 'x@y.de')
|
||||
assert.equal(customerTitle({}), '–')
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { validateEmployeeForm } = await loadModule('src/lib/employeeForm.js')
|
||||
const base = { displayName: 'Max Muster', email: 'max@webklar.com', password: '', shortcode: '', isAdmin: false }
|
||||
const NEED = 'Name und E-Mail sind erforderlich.'
|
||||
|
||||
test('validateEmployeeForm: neuer Mitarbeiter', () => {
|
||||
assert.equal(validateEmployeeForm({ ...base, displayName: ' ', password: '12345678' }, false), NEED)
|
||||
assert.equal(validateEmployeeForm({ ...base, email: '', password: '12345678' }, false), NEED)
|
||||
assert.equal(validateEmployeeForm({ ...base, password: '1234567' }, false), 'Das Passwort muss mindestens 8 Zeichen haben.')
|
||||
assert.equal(validateEmployeeForm({ ...base, password: '12345678' }, false), null)
|
||||
})
|
||||
|
||||
test('validateEmployeeForm: Mitarbeiter bearbeiten', () => {
|
||||
assert.equal(validateEmployeeForm(base, true), null) // leeres Passwort = unverändert
|
||||
assert.equal(validateEmployeeForm({ ...base, password: 'abc' }, true), 'Ein neues Passwort muss mindestens 8 Zeichen haben.')
|
||||
assert.equal(validateEmployeeForm({ ...base, password: 'abcdefgh' }, true), null)
|
||||
assert.equal(validateEmployeeForm({ ...base, email: ' ' }, true), NEED)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { isNegText, noticeProps } = await loadModule('src/lib/financeUi.js')
|
||||
const { euro, euroWhole, signedEuro } = await loadModule('src/lib/financeFormat.js')
|
||||
|
||||
test('isNegText: nur das angezeigte Minus zählt', () => {
|
||||
assert.equal(isNegText(euro(-449)), true)
|
||||
assert.equal(isNegText(signedEuro(-449)), true)
|
||||
assert.equal(isNegText(euroWhole(-123456)), true)
|
||||
assert.equal(isNegText(euroWhole(-40)), false) // „0 €“: Rundung auf 0 trägt kein Vorzeichen
|
||||
assert.equal(isNegText(signedEuro(16879)), false)
|
||||
assert.equal(isNegText(euro(null)), false) // „–“ (U+2013)
|
||||
for (const v of ['-4,49 €', '', undefined, null, 5]) assert.equal(isNegText(v), false)
|
||||
})
|
||||
|
||||
test('noticeProps: Klasse und Rolle je Ton', () => {
|
||||
assert.deepEqual(noticeProps('ok'), { className: 'msg ok', role: 'status' })
|
||||
assert.deepEqual(noticeProps('warn'), { className: 'msg warn', role: 'alert' })
|
||||
assert.deepEqual(noticeProps('info'), { className: 'msg muted', role: 'status' })
|
||||
assert.deepEqual(noticeProps(undefined), { className: 'msg muted', role: 'status' })
|
||||
assert.deepEqual(noticeProps('warn', 'fin-notice'), { className: 'msg warn fin-notice', role: 'alert' })
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { costLabel, termLabel, noticeLabel, deadlineView } = await loadModule('src/lib/legalView.js')
|
||||
const NB = ' '
|
||||
const today = '2026-09-30'
|
||||
const active = (noticeBy, periodEnd = '2027-03-12') => ({ state: 'active', noticeBy, periodEnd })
|
||||
const v = (r) => deadlineView(r, today)
|
||||
|
||||
test('Vertragsliste: Kosten, Laufzeit, Frist wie bisher', () => {
|
||||
assert.equal(costLabel({ cost: null }), '–')
|
||||
assert.equal(costLabel({ cost: 12.5, costInterval: 'month' }), `12,50${NB}€/Monat`)
|
||||
assert.equal(costLabel({ cost: 120, costInterval: 'year' }), `120,00${NB}€/Jahr`)
|
||||
assert.equal(termLabel({}), '–')
|
||||
assert.equal(termLabel({ termMonths: 0 }), 'jederzeit kündbar')
|
||||
assert.equal(termLabel({ termMonths: 12, renewalMonths: 12 }), '12 Monate, dann je 12 Monate')
|
||||
assert.equal(termLabel({ termMonths: 24, renewalMonths: 0 }), '24 Monate, endet danach')
|
||||
assert.equal(noticeLabel({}), '–')
|
||||
assert.equal(noticeLabel({ noticeValue: 1, noticeUnit: 'months' }), '1 Monate')
|
||||
assert.equal(noticeLabel({ noticeValue: 14, noticeUnit: 'days' }), '14 Tage')
|
||||
})
|
||||
|
||||
test('deadlineView: Badge nur bei rot und orange, Text wörtlich wie bisher', () => {
|
||||
assert.deepEqual(v(active('2026-10-15')), {
|
||||
text: 'kündbar bis 15.10.2026 (Periode endet 12.03.2027)',
|
||||
lead: 'kündbar bis 15.10.2026', rest: '(Periode endet 12.03.2027)', badge: 'danger', muted: false,
|
||||
})
|
||||
assert.equal(v(active('2026-10-30')).badge, 'danger') // 30 Tage
|
||||
assert.equal(v(active('2026-10-31')).badge, 'warn') // 31 Tage
|
||||
assert.equal(v(active('2026-12-29')).badge, 'warn') // 90 Tage
|
||||
const normal = v(active('2026-12-30')) // 91 Tage
|
||||
assert.deepEqual([normal.badge, normal.lead === normal.text, normal.rest], [null, true, ''])
|
||||
})
|
||||
|
||||
test('deadlineView: Text und Dämpfung der übrigen Zustände', () => {
|
||||
assert.deepEqual([v({ state: 'anytime' }).text, v({ state: 'anytime' }).muted], ['jederzeit kündbar', false])
|
||||
assert.equal(v({ state: 'ends', periodEnd: '2027-01-31' }).text, 'endet am 31.01.2027')
|
||||
assert.deepEqual([v({ state: 'ended', periodEnd: '2026-01-31' }).text, v({ state: 'ended', periodEnd: '2026-01-31' }).muted], ['beendet seit 31.01.2026', true])
|
||||
assert.equal(v({ state: 'cancelled', periodEnd: '2027-01-31' }).text, 'gekündigt zum 31.01.2027')
|
||||
assert.equal(v({ state: 'cancelled', periodEnd: null }).text, 'gekündigt')
|
||||
assert.deepEqual([v({ state: 'incomplete' }).text, v({ state: 'incomplete' }).muted], ['Fristen fehlen', true])
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// P6: Zeilen der Abo-Angebote (Filter, Aktionen, Mail-Zeile) als reine Helfer
|
||||
const { offerStatus, filterOffers, offerRowActions, offerMailLine } = await loadModule('src/lib/offerFormat.js')
|
||||
|
||||
test('offerStatus: Tonnamen und Beschriftungen sind festgelegt', () => {
|
||||
assert.deepEqual(offerStatus('offen'), { tone: 'warn', label: 'offen' })
|
||||
assert.deepEqual(offerStatus('aktiv'), { tone: 'ok', label: 'aktiv' })
|
||||
assert.deepEqual(offerStatus('abgelaufen'), { tone: 'muted', label: 'abgelaufen' })
|
||||
assert.deepEqual(offerStatus('zurueckgezogen'), { tone: 'muted', label: 'zurückgezogen' })
|
||||
})
|
||||
|
||||
test('filterOffers: "open" zeigt offen und aktiv, "all" alles, ohne Liste leer, Eingabe bleibt unveraendert', () => {
|
||||
const offers = [
|
||||
{ id: '1', status: 'offen' },
|
||||
{ id: '2', status: 'aktiv' },
|
||||
{ id: '3', status: 'abgelaufen' },
|
||||
{ id: '4', status: 'zurueckgezogen' },
|
||||
]
|
||||
const copy = structuredClone(offers)
|
||||
assert.deepEqual(filterOffers(offers, 'open').map((o) => o.id), ['1', '2'])
|
||||
assert.deepEqual(filterOffers(offers, 'all').map((o) => o.id), ['1', '2', '3', '4'])
|
||||
assert.deepEqual(filterOffers(null, 'open'), [])
|
||||
assert.deepEqual(filterOffers(null, 'all'), [])
|
||||
assert.deepEqual(offers, copy)
|
||||
})
|
||||
|
||||
test('offerRowActions: E-Mail-Knopf und Zurueckziehen je nach Status', () => {
|
||||
assert.deepEqual(offerRowActions({ status: 'offen', mailSendable: true }), { sendLabel: 'E-Mail senden', canWithdraw: true })
|
||||
assert.deepEqual(offerRowActions({ status: 'offen', mailSendable: true, notifiedAt: '2026-09-30T10:05:00.000Z' }), { sendLabel: 'Erneut senden', canWithdraw: true })
|
||||
assert.deepEqual(offerRowActions({ status: 'aktiv', mailSendable: true }), { sendLabel: 'E-Mail senden', canWithdraw: false })
|
||||
assert.deepEqual(offerRowActions({ status: 'abgelaufen', mailSendable: false }), { sendLabel: '', canWithdraw: true })
|
||||
assert.deepEqual(offerRowActions({ status: 'zurueckgezogen' }), { sendLabel: '', canWithdraw: false })
|
||||
})
|
||||
|
||||
test('offerMailLine: gesendet, nicht verschickt, ohne Adresse, sonst keine Zeile', () => {
|
||||
assert.deepEqual(offerMailLine({ notifiedAt: '2026-09-30T10:05:00.000Z' }), { tone: 'muted', text: 'E-Mail gesendet 30.09.26, 12:05' })
|
||||
assert.deepEqual(offerMailLine({ mailSendable: true }), { tone: 'warn', text: 'E-Mail nicht verschickt' })
|
||||
assert.deepEqual(offerMailLine({ status: 'offen', customerEmail: '' }), { tone: 'warn', text: 'Keine E-Mail-Adresse hinterlegt' })
|
||||
assert.equal(offerMailLine({ status: 'abgelaufen', customerEmail: '' }), null)
|
||||
assert.equal(offerMailLine({ status: 'offen', customerEmail: 'a@b.de' }), null)
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js')
|
||||
const { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js')
|
||||
|
||||
const P = [
|
||||
{ $id: 'a' },
|
||||
@@ -76,3 +76,21 @@ test('hiddenMatches: Treffer, die die aktuelle Ansicht ausblendet', () => {
|
||||
assert.deepEqual(hiddenMatches(list, 'everything', shk, { isAdmin: true }), [], 'zeigt schon alles')
|
||||
assert.deepEqual(hiddenMatches(list, 'all', (p) => matchesSearch(p, 'olz'), { isAdmin: true }), [])
|
||||
})
|
||||
|
||||
const counts = { all: 5, unassigned: 2, assigned: 3, archived: 4, templates: 1, everything: 10 }
|
||||
test('projectTabs: sechs Reiter für Admins, Wortlaut und Zähler', () => {
|
||||
assert.deepEqual(projectTabs(counts, { isAdmin: true }), [
|
||||
{ id: 'all', label: 'Aktive Projekte', count: 5 },
|
||||
{ id: 'unassigned', label: 'Unzugeordnet', count: 2 },
|
||||
{ id: 'assigned', label: 'Zugeordnet', count: 3 },
|
||||
{ id: 'templates', label: 'Vorlagen', count: 1 },
|
||||
{ id: 'archived', label: 'Archiviert', count: 4 },
|
||||
{ id: 'everything', label: 'Alle inkl. Archiv', count: 10 },
|
||||
])
|
||||
})
|
||||
test('projectTabs: ohne Admin keine Vorlagen, Zähler passen zur Ansicht', () => {
|
||||
assert.deepEqual(projectTabs(counts).map((t) => t.id), ['all', 'unassigned', 'assigned', 'archived', 'everything'])
|
||||
for (const t of projectTabs(projectCounts(P, { isAdmin: true }), { isAdmin: true })) {
|
||||
assert.equal(t.count, projectsInView(P, t.id, { isAdmin: true }).length)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { PROJECT_TYPES, typeOf, isProjectReady, projectActions, projectTicketRef, updatedLabel } = await loadModule('src/lib/projectRow.js')
|
||||
|
||||
test('typeOf: Vorlage vor Website vor Preview vor Projekt', () => {
|
||||
assert.equal(typeOf({ isTemplate: true, isPublic: true, subdomain: 'x' }), 'template')
|
||||
assert.equal(typeOf({ isPublic: true, subdomain: 'x' }), 'website')
|
||||
assert.equal(typeOf({ subdomain: 'x' }), 'preview')
|
||||
assert.equal(typeOf({}), 'project')
|
||||
})
|
||||
test('isProjectReady: nur ready/deployed, provisioningStatus hat Vorrang', () => {
|
||||
assert.equal(isProjectReady({ status: 'ready' }), true)
|
||||
assert.equal(isProjectReady({ status: 'DEPLOYED' }), true)
|
||||
assert.equal(isProjectReady({ provisioningStatus: 'pending', status: 'ready' }), false)
|
||||
assert.equal(isProjectReady({ status: 'building' }), false)
|
||||
assert.equal(isProjectReady({}), false)
|
||||
})
|
||||
test('PROJECT_TYPES: Reihenfolge, Texte und Töne unverändert', () => {
|
||||
assert.deepEqual(Object.keys(PROJECT_TYPES), ['preview', 'website', 'template', 'project'])
|
||||
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.label), ['Preview (mit Login)', 'Oeffentliche Website', 'Vorlage', 'Internes Projekt (kein Hosting)'])
|
||||
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.short), ['Preview', 'Website', 'Vorlage', 'Projekt'])
|
||||
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.tone), ['info', 'ok', 'warn', 'muted'])
|
||||
assert.equal(PROJECT_TYPES.template.adminOnly, true)
|
||||
assert.equal(PROJECT_TYPES.project.hint, 'Nur Gitea-Repository, keine Website/Subdomain')
|
||||
})
|
||||
test('projectActions: gehostet und nicht deployt', () => {
|
||||
const ready = projectActions({ subdomain: 'a', status: 'ready', previewUrl: 'https://a.project.webklar.com', giteaRepoUrl: 'https://git/x' })
|
||||
assert.deepEqual(ready, { type: 'preview', hosted: true, ready: true, host: false, preview: true, refresh: true, gitea: true, hostedBadge: { text: 'gehostet', tone: 'ok' } })
|
||||
const open = projectActions({ subdomain: 'a', status: 'provisioning', previewUrl: 'https://a.project.webklar.com' })
|
||||
assert.deepEqual(open, { type: 'preview', hosted: true, ready: false, host: true, preview: false, refresh: false, gitea: false, hostedBadge: { text: 'nicht deployt', tone: 'warn' } })
|
||||
})
|
||||
test('projectActions: archiviert, ohne Website, Vorlage', () => {
|
||||
const arch = projectActions({ subdomain: 'a', status: 'ready', archived: true, previewUrl: 'u' })
|
||||
assert.deepEqual([arch.host, arch.preview, arch.refresh, arch.hostedBadge], [false, true, false, null])
|
||||
const archOpen = projectActions({ subdomain: 'a', status: 'error', archived: true })
|
||||
assert.deepEqual([archOpen.host, archOpen.refresh, archOpen.hostedBadge], [false, false, null])
|
||||
const intern = projectActions({ giteaRepoUrl: 'https://git/x' })
|
||||
assert.deepEqual([intern.type, intern.host, intern.preview, intern.refresh, intern.hostedBadge, intern.gitea], ['project', false, false, false, null, true])
|
||||
const tpl = projectActions({ isTemplate: true, subdomain: 't', status: 'deployed' })
|
||||
assert.deepEqual([tpl.type, tpl.refresh, tpl.host], ['template', true, false])
|
||||
})
|
||||
test('projectTicketRef: woid aus der Liste, sonst die $id, ohne Ticket leer', () => {
|
||||
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: '100131' }), '100131')
|
||||
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: 100131 }), '100131')
|
||||
assert.equal(projectTicketRef({ ticketId: 'zzz' }, {}), 'zzz')
|
||||
assert.equal(projectTicketRef({ ticketId: '' }, { abc: '1' }), '')
|
||||
assert.equal(projectTicketRef({}, {}), '')
|
||||
})
|
||||
test('updatedLabel: deutsch relativ, Titel in Berliner Zeit, ungültig = "-"', () => {
|
||||
const now = new Date('2026-10-05T10:00:00.000Z')
|
||||
assert.deepEqual(updatedLabel('2026-10-05T07:00:00.000Z', now), { text: 'vor etwa 3 Stunden', title: '05.10.26, 09:00' })
|
||||
assert.equal(updatedLabel('2026-10-04T10:00:00.000Z', now).text, 'vor 1 Tag')
|
||||
for (const bad of ['', 'kaputt', undefined]) assert.deepEqual(updatedLabel(bad, now), { text: '-', title: '' })
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { previewColorTone, previewStateTone, analyseColorTone, statusValueTone } = await loadModule('src/components/projectTones.js')
|
||||
|
||||
test('previewColorTone', () => {
|
||||
assert.deepEqual(['green', 'yellow', 'red', 'grey', undefined].map(previewColorTone), ['ok', 'warn', 'danger', 'plain', 'plain'])
|
||||
})
|
||||
test('previewStateTone', () => {
|
||||
assert.deepEqual(['online', 'building', 'starting', 'sleeping', 'failed', 'off', undefined, 'constructor'].map(previewStateTone), ['ok', 'warn', 'warn', 'warn', 'danger', 'plain', 'plain', 'plain'])
|
||||
})
|
||||
test('analyseColorTone', () => {
|
||||
assert.deepEqual(['green', 'red', 'grey', undefined].map(analyseColorTone), ['ok', 'danger', 'muted', 'muted'])
|
||||
})
|
||||
test('statusValueTone: wie die Farben in AssignedProjectCard heute', () => {
|
||||
assert.deepEqual(['ready', 'LIVE', 'online', 'provisioning', 'pending', 'building', 'error', 'failed'].map(statusValueTone), ['ok', 'ok', 'ok', 'warn', 'warn', 'info', 'danger', 'danger'])
|
||||
assert.deepEqual(['deployed', '', undefined, 'irgendwas'].map(statusValueTone), ['muted', 'muted', 'muted', 'muted'])
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { formatBytes, formatDelta, ageLabel, usedPct, axisTimeKind, formatAxisTick, formatLegendTime } = await loadModule('src/lib/serverpulsFormat.js')
|
||||
const { formatBytes, formatDelta, deltaDir, deltaSign, levelTone, ageLabel, usedPct, axisTimeKind, formatAxisTick, formatLegendTime } = await loadModule('src/lib/serverpulsFormat.js')
|
||||
|
||||
test('formatBytes: de-DE, Basis 1024', () => {
|
||||
assert.equal(formatBytes(1288490189), '1,2 GB')
|
||||
@@ -20,6 +20,19 @@ test('formatDelta', () => {
|
||||
assert.equal(formatDelta(0).dir, 'flat')
|
||||
})
|
||||
|
||||
test('deltaDir und deltaSign: Richtung und Vorzeichen der Kachel-Deltas', () => {
|
||||
assert.deepEqual([deltaDir(0), deltaDir(5e-10), deltaDir(-5e-10)], ['flat', 'flat', 'flat'])
|
||||
assert.equal(deltaDir(0.4), 'up')
|
||||
assert.equal(deltaDir(-0.4), 'down')
|
||||
assert.deepEqual([deltaSign('up'), deltaSign('down'), deltaSign('flat')], ['+', '−', ''])
|
||||
})
|
||||
|
||||
test('levelTone: Badge-Ton je Stufe', () => {
|
||||
assert.deepEqual(['ok', 'medium', 'critical'].map(levelTone), ['ok', 'warn', 'danger'])
|
||||
assert.deepEqual([levelTone('low'), levelTone(undefined), levelTone('x')], ['muted', 'muted', 'muted'])
|
||||
assert.deepEqual([levelTone('constructor'), levelTone('toString'), levelTone('__proto__')], ['muted', 'muted', 'muted'])
|
||||
})
|
||||
|
||||
test('ageLabel: stale ab > 15 Min.', () => {
|
||||
const now = 1_000_000_000_000
|
||||
const a16 = ageLabel(now - 16 * 60000, now)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// P6: Faelligkeitsspalte der E-Mail-Speicher-Aufgaben als reiner Helfer
|
||||
const { storageDue, isOverdue } = await loadModule('src/lib/storageFormat.js')
|
||||
const t0 = Date.parse('2026-10-05T08:00:00Z')
|
||||
|
||||
test('storageDue: Einrichten sofort, Verringern zum Periodenende (ueberfaellig = warn)', () => {
|
||||
assert.deepEqual(
|
||||
storageDue({ kind: 'einrichten', createdAt: '2026-10-01T08:30:00.000Z' }, t0),
|
||||
{ tone: 'warn', label: 'jetzt einrichten', sub: 'bestellt 01.10.26, 10:30', subMuted: true },
|
||||
)
|
||||
assert.deepEqual(
|
||||
storageDue({ kind: 'senken', dueAt: '2026-10-04T00:00:00.000Z' }, t0),
|
||||
{ tone: 'warn', label: 'zum Periodenende', sub: '04.10.2026', subMuted: false },
|
||||
)
|
||||
const later = storageDue({ kind: 'senken', dueAt: '2026-10-20T00:00:00.000Z' }, t0)
|
||||
assert.equal(later.tone, 'muted')
|
||||
assert.equal(later.sub, '20.10.2026')
|
||||
})
|
||||
|
||||
test('isOverdue: vergangenes dueAt ja, zukuenftiges oder fehlendes nein', () => {
|
||||
assert.equal(isOverdue({ dueAt: '2026-10-04T00:00:00.000Z' }, t0), true)
|
||||
assert.equal(isOverdue({ dueAt: '2026-10-20T00:00:00.000Z' }, t0), false)
|
||||
assert.equal(isOverdue({}, t0), false)
|
||||
})
|
||||
Reference in new issue
Block a user