Compare commits
50
Commits
35e2761e68
...
fa1ce2c4cb
No files matched your search
@@ -7,8 +7,7 @@ export default function AdminRoute({ children }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center p-4" style={{ marginTop: 80 }}>
|
<div className="loading">
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Wird geladen …</p>
|
<p>Wird geladen …</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
|
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
|
||||||
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
|
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
|
||||||
|
import { Badge, Button, Dialog, Field } from './ui'
|
||||||
const COLORS = { green: 'var(--green-fg)', red: 'var(--red-fg)', grey: 'var(--text-2)' }
|
import { analyseColorTone } from './projectTones'
|
||||||
|
|
||||||
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
|
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
|
||||||
export default function AnalyseRow({ projectId }) {
|
export default function AnalyseRow({ projectId }) {
|
||||||
@@ -54,80 +54,87 @@ export default function AnalyseRow({ projectId }) {
|
|||||||
} catch { /* Kopieren nicht möglich: Text bleibt markierbar */ }
|
} catch { /* Kopieren nicht möglich: Text bleibt markierbar */ }
|
||||||
}
|
}
|
||||||
const status = analyseStatus(data)
|
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 (
|
return (
|
||||||
<div style={{ marginTop: 10, fontSize: 13 }}>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="pv-block">
|
||||||
<span className="text-grey">Website-Analyse:</span>
|
<div className="pv-row">
|
||||||
|
<span className="muted">Website-Analyse:</span>
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<span style={{ color: COLORS.red }}>{loadError}</span>
|
<span className="msg err" role="alert">{loadError}</span>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<span style={{ color: COLORS[status.color] }}>{status.text}</span>
|
<Badge tone={analyseColorTone(status.color)} className="wrap">{status.text}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-grey">Lädt …</span>
|
<span className="muted">Lädt …</span>
|
||||||
)}
|
)}
|
||||||
{status.hint && <span className="text-grey">({status.hint})</span>}
|
{status.hint && <span className="muted">({status.hint})</span>}
|
||||||
{status.canEnable && !mode && (
|
{status.canEnable && mode !== 'off' && (
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
|
<button type="button" className="link" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
|
||||||
)}
|
)}
|
||||||
{status.canDisable && !mode && (
|
{status.canDisable && !mode && (
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setMode('off')}>Ausschalten</button>
|
<button type="button" className="link" onClick={() => setMode('off')}>Ausschalten</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'on' && data && (() => {
|
{mode === 'off' && (
|
||||||
const label = textformLabel(data.avvVersion)
|
<div className="pv-row">
|
||||||
const links = legalLinks(data.avvVersion)
|
<span>Website-Analyse wirklich ausschalten?</span>
|
||||||
return (
|
<button type="button" className="link-danger" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<button type="button" className="link" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||||
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'flex-start' }}>
|
</div>
|
||||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} style={{ marginTop: 3 }} />
|
)}
|
||||||
|
|
||||||
|
{mode !== 'on' && errorLine}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
<span>
|
||||||
{label.before}
|
{label.before}
|
||||||
<a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
|
<a className="link" href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
|
||||||
{label.between}
|
{label.between}
|
||||||
<a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
|
<a className="link" href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
|
||||||
{label.after}
|
{label.after}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Field label="Datum der Zustimmung:" htmlFor={dateId}>
|
||||||
<label>
|
<input id={dateId} className="input" type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
|
||||||
Datum der Zustimmung:{' '}
|
</Field>
|
||||||
<input type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
|
<div className="field" role="radiogroup" aria-label="Weg der Zustimmung">
|
||||||
</label>
|
<span className="strong">Weg:</span>
|
||||||
<span role="radiogroup" aria-label="Weg der Zustimmung" style={{ display: 'inline-flex', gap: 10, alignItems: 'center' }}>
|
|
||||||
<span>Weg:</span>
|
|
||||||
{['chat', 'email'].map((c) => (
|
{['chat', 'email'].map((c) => (
|
||||||
<label key={c} style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
<label key={c} className="check">
|
||||||
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
|
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
|
||||||
{channelLabel(c)}
|
{channelLabel(c)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{privacyBlocked && <div style={{ color: COLORS.red }}>{privacyHint(data.privacy)}</div>}
|
{privacyBlocked && <p className="msg err">{privacyHint(data.privacy)}</p>}
|
||||||
<details>
|
<details>
|
||||||
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
|
<summary>Textvorlage für den Chat</summary>
|
||||||
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{chatTemplate(data.avvVersion)}</p>
|
<p className="prose">{chatTemplate(data.avvVersion)}</p>
|
||||||
<button type="button" className="btn btn-sm" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</button>
|
<Button type="button" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</Button>
|
||||||
</details>
|
</details>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
</Dialog>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{error && <div style={{ marginTop: 6, color: COLORS.red }}>{error}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaCodeBranch } from 'react-icons/fa'
|
import { Badge, Button } from './ui'
|
||||||
import { FaLinkSlash } from 'react-icons/fa6'
|
|
||||||
import PreviewBlock from './PreviewBlock'
|
import PreviewBlock from './PreviewBlock'
|
||||||
import AnalyseRow from './AnalyseRow'
|
import AnalyseRow from './AnalyseRow'
|
||||||
import GiteaLinkButton from './GiteaLinkButton'
|
import GiteaLinkButton from './GiteaLinkButton'
|
||||||
import PreviewLinkButton from './PreviewLinkButton'
|
import PreviewLinkButton from './PreviewLinkButton'
|
||||||
|
import { statusValueTone } from './projectTones'
|
||||||
import { suggestSubdomain } from '../lib/subdomain'
|
import { suggestSubdomain } from '../lib/subdomain'
|
||||||
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
|
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
|
||||||
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
|
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
// Status und Provisioning: Wert als Badge, ohne Wert nichts
|
||||||
ready: 'var(--green-fg)',
|
function StatusValue({ label, value }) {
|
||||||
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 }) {
|
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const color = STATUS_COLORS[String(value).toLowerCase()] || 'var(--text-2)'
|
return <span>{label}: <Badge tone={statusValueTone(value)}>{value}</Badge></span>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
|
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
|
||||||
@@ -79,65 +60,47 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--bg-subtle)', border: '1px solid var(--line)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
|
<div className="proj-item">
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
<div className="proj-head">
|
||||||
<div style={{ minWidth: 0 }}>
|
<div className="proj-main">
|
||||||
<strong>{project.projectName}</strong>
|
<strong>{project.projectName}</strong>
|
||||||
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
{project.subdomain && <>{' '}<span className="muted small">({project.subdomain})</span></>}
|
||||||
{project.repoFullName && (
|
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
|
||||||
<div className="text-grey" style={{ fontSize: 12, marginTop: 4 }}>
|
|
||||||
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="row-actions">
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
|
||||||
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
||||||
<GiteaLinkButton href={project.giteaRepoUrl} />
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
{showActions && onUnassign && (
|
{showActions && onUnassign && (
|
||||||
<button
|
<Button variant="danger" type="button" title="Vom Ticket lösen" aria-label="Vom Ticket lösen" onClick={() => onUnassign(project.$id)}>
|
||||||
type="button"
|
Lösen
|
||||||
title="Vom Ticket lösen"
|
</Button>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status- und Provisioning-Zeile */}
|
{(project.status || project.provisioningStatus) && (
|
||||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="proj-meta">
|
||||||
<StatusBadge label="Status" value={project.status} />
|
<StatusValue label="Status" value={project.status} />
|
||||||
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
<StatusValue label="Provisioning" value={project.provisioningStatus} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
|
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
|
||||||
|
|
||||||
{showActions && project.subdomain && (
|
{showActions && project.subdomain && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<>
|
||||||
<button
|
<button type="button" className="link" aria-expanded={showPreviewBlock} onClick={() => setShowPreviewBlock((v) => !v)}>
|
||||||
type="button"
|
|
||||||
className="btn btn-sm"
|
|
||||||
aria-expanded={showPreviewBlock}
|
|
||||||
onClick={() => setShowPreviewBlock((v) => !v)}
|
|
||||||
>
|
|
||||||
Preview {showPreviewBlock ? '▴' : '▾'}
|
Preview {showPreviewBlock ? '▴' : '▾'}
|
||||||
</button>
|
</button>
|
||||||
{showPreviewBlock && (
|
{showPreviewBlock && <PreviewBlock key={project.$id} projectId={project.$id} />}
|
||||||
<div style={{ marginTop: 8 }}>
|
</>
|
||||||
<PreviewBlock key={project.$id} projectId={project.$id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="row-actions">
|
||||||
{needsPreviewButton(project) && !showPreviewInput && (
|
{needsPreviewButton(project) && !showPreviewInput && (
|
||||||
<button type="button" className="btn btn-sm" onClick={openPreviewInput}>
|
<button type="button" className="link" onClick={openPreviewInput}>
|
||||||
Preview einschalten
|
Preview einschalten
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -145,32 +108,23 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
|||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
style={{ maxWidth: 200 }}
|
aria-label="Subdomain für die Preview"
|
||||||
value={subdomain}
|
value={subdomain}
|
||||||
onChange={(e) => setSubdomain(e.target.value)}
|
onChange={(e) => setSubdomain(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button type="button" disabled={!subdomain || previewBusy} onClick={handleEnablePreview}>
|
||||||
type="button"
|
|
||||||
className="btn btn-sm"
|
|
||||||
disabled={!subdomain || previewBusy}
|
|
||||||
onClick={handleEnablePreview}
|
|
||||||
>
|
|
||||||
Einschalten
|
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
|
Pushes nachtragen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(previewMessage || pushMessage) && (
|
{previewMessage && <p className="msg muted">{previewMessage}</p>}
|
||||||
<div className="text-grey" style={{ marginTop: 6, fontSize: 12 }}>
|
{pushMessage && <p className="msg muted">{pushMessage}</p>}
|
||||||
{previewMessage && <div>{previewMessage}</div>}
|
</>
|
||||||
{pushMessage && <div>{pushMessage}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
|
|
||||||
|
|
||||||
export async function copyText(text) {
|
export async function copyText(text) {
|
||||||
try {
|
try {
|
||||||
@@ -53,26 +52,26 @@ export default function CopyableCredential({ value, secret = false, label = 'Wer
|
|||||||
{secret && (
|
{secret && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="credential-action"
|
className="link"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setVisible((current) => !current)
|
setVisible((current) => !current)
|
||||||
}}
|
}}
|
||||||
title={visible ? `${label} ausblenden` : `${label} anzeigen`}
|
title={visible ? `${label} verbergen` : `${label} anzeigen`}
|
||||||
aria-label={visible ? `${label} ausblenden` : `${label} anzeigen`}
|
aria-label={visible ? `${label} verbergen` : `${label} anzeigen`}
|
||||||
>
|
>
|
||||||
{visible ? <FaEyeSlash /> : <FaEye />}
|
{visible ? 'Verbergen' : 'Anzeigen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="credential-action"
|
className="link"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title={copied ? 'Kopiert' : `${label} kopieren`}
|
title={copied ? `${label} kopiert` : `${label} kopieren`}
|
||||||
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
|
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
|
||||||
>
|
>
|
||||||
{copied ? <FaCheck className="credential-check" /> : <FaCopy />}
|
{copied ? 'Kopiert' : 'Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import { SiGitea } from 'react-icons/si'
|
export default function GiteaLinkButton({ href, label = 'In Gitea öffnen' }) {
|
||||||
|
|
||||||
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
|
|
||||||
if (!href) return null
|
if (!href) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a className="link" href={href} target="_blank" rel="noopener noreferrer">
|
||||||
href={href}
|
{label}
|
||||||
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>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { modeOptionsFor, previewStatus, modeHint, isDevUnavailableError, NO_DEV_HINT } from '../lib/previewView'
|
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 {
|
import {
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
setPreviewMode,
|
setPreviewMode,
|
||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
stopPreviewDev,
|
stopPreviewDev,
|
||||||
fetchPreviewLog,
|
fetchPreviewLog,
|
||||||
} from '../lib/previewApi'
|
} 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 NOT_ACTIVE = 'Preview-Runner ist nicht aktiv'
|
||||||
|
|
||||||
const logDate = (at) => {
|
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 view = data ? { ...data } : { state: 'off' }
|
||||||
const status = previewStatus(view)
|
const status = previewStatus(view)
|
||||||
@@ -182,18 +183,15 @@ export default function PreviewBlock({ projectId }) {
|
|||||||
const dev = isDevPreview(data)
|
const dev = isDevPreview(data)
|
||||||
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
|
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
|
||||||
|
|
||||||
const small = { fontSize: 12 }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontSize: 13 }}>
|
<div className="pv-block">
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<div className="pv-row">
|
||||||
<label className="text-grey" htmlFor={`pv-mode-${projectId}`} style={small}>Modus</label>
|
<label className="muted xs" htmlFor={`pv-mode-${projectId}`}>Modus</label>
|
||||||
<select
|
<select
|
||||||
id={`pv-mode-${projectId}`}
|
id={`pv-mode-${projectId}`}
|
||||||
className="form-control"
|
className="select pv-mode"
|
||||||
style={{ maxWidth: 200 }}
|
|
||||||
value={mode}
|
value={mode}
|
||||||
disabled={busy || inactive}
|
disabled={busy || inactive}
|
||||||
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
|
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>)}
|
{modeOptionsFor(data.devAvailable).map((o) => <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{data.devAvailable === false && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{NO_DEV_HINT}</div>}
|
{data.devAvailable === false && <p className="muted xs">{NO_DEV_HINT}</p>}
|
||||||
{hint && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{hint}</div>}
|
{hint && <p className="muted xs">{hint}</p>}
|
||||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
<div className="pv-row">
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dotColor(STATE_OF_COLOR[status.color]), display: 'inline-block', flexShrink: 0, marginTop: 4 }} />
|
<Badge tone={previewColorTone(status.color)} className="wrap">{status.text}</Badge>
|
||||||
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{data.state === 'failed' && data.error && !isDevUnavailableError(data.error) && (
|
{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>}
|
{inactive && <p className="muted xs">{NOT_ACTIVE}.</p>}
|
||||||
{error && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
|
{error && <p className="msg err" role="alert">{error}</p>}
|
||||||
|
|
||||||
{data && !inactive && (
|
{data && !inactive && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
|
<div className="pv-row">
|
||||||
<button type="button" className="btn btn-sm" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
|
<Button type="button" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
|
||||||
Neu bauen
|
Neu bauen
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" className="btn btn-sm" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
|
<Button type="button" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
|
||||||
Protokoll ansehen
|
Protokoll ansehen
|
||||||
</button>
|
</Button>
|
||||||
{dev && (
|
{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
|
Jetzt starten
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
|
<Button type="button" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
|
||||||
Stoppen
|
Stoppen
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="select"
|
||||||
style={{ maxWidth: '100%' }}
|
|
||||||
aria-label="Lauf wählen"
|
aria-label="Lauf wählen"
|
||||||
value={logFile}
|
value={logFile}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -250,47 +246,39 @@ export default function PreviewBlock({ projectId }) {
|
|||||||
<option value="">Lauf wählen …</option>
|
<option value="">Lauf wählen …</option>
|
||||||
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
|
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{logFile && (
|
{logFile && <pre className="pv-log">{logText || '…'}</pre>}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 14 }}>
|
<h4 className="pv-title">Variablen</h4>
|
||||||
<strong style={small}>Variablen</strong>
|
{names.length === 0 && <p className="muted xs">Keine Variablen.</p>}
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
|
|
||||||
{names.map((n) => (
|
{names.map((n) => (
|
||||||
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid var(--line)' }}>
|
<div key={n} className="pv-var">
|
||||||
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
|
<code>{n}</code>
|
||||||
<span className="text-grey">•••</span>
|
<span className="muted">•••</span>
|
||||||
{editName === n ? (
|
{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)} />
|
<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="btn btn-sm" onClick={handleChange}>Übernehmen</button>
|
<button type="button" className="link" onClick={handleChange}>Übernehmen</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
|
<button type="button" className="link" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
|
<button type="button" className="link" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginTop: 8 }}>
|
<div className="pv-add">
|
||||||
<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="text" className="input" 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)} />
|
<input type="password" autoComplete="off" className="input" 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>
|
<Button type="button" disabled={busy} onClick={handleAdd}>Hinzufügen</Button>
|
||||||
</div>
|
</div>
|
||||||
{envError && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 4 }}>{envError}</div>}
|
{envError && <p className="msg err">{envError}</p>}
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div className="pv-row">
|
||||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
|
<Button type="button" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-grey" style={{ ...small, marginTop: 6 }}>Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</div>
|
<p className="muted xs">Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { FaEye } from 'react-icons/fa6'
|
|
||||||
import { createPreviewLink } from '../lib/teamApi'
|
import { createPreviewLink } from '../lib/teamApi'
|
||||||
import { openInNewTab } from '../lib/openInNewTab'
|
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.
|
// 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
|
if (!href) return null
|
||||||
|
|
||||||
const open = (event) => {
|
const open = (event) => {
|
||||||
@@ -16,28 +15,8 @@ export default function PreviewLinkButton({ href, subdomain, title = 'Preview oe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a className="link" href={href} onClick={open} target="_blank" rel="noopener noreferrer">
|
||||||
href={href}
|
{label}
|
||||||
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>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { FaPlus, FaLink } from 'react-icons/fa6'
|
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
|
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
|
||||||
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
|
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
|
||||||
@@ -7,6 +6,7 @@ import { slugify } from '../lib/subdomain'
|
|||||||
import AssignedProjectCard from './AssignedProjectCard'
|
import AssignedProjectCard from './AssignedProjectCard'
|
||||||
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
||||||
import { kindLabelFor } from '../lib/assignFlow'
|
import { kindLabelFor } from '../lib/assignFlow'
|
||||||
|
import { Card, Button, Dialog } from './ui'
|
||||||
|
|
||||||
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
|
// 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.
|
// 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(() => {})
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!ticket?.$id) return
|
if (!ticketId) return
|
||||||
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
|
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticketId)])
|
||||||
setAssigned(mine)
|
setAssigned(mine)
|
||||||
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
|
setAvailable(assignableProjects(all, ticketRef.current, new Set(mine.map((p) => p.$id))))
|
||||||
}, [ticket, fetchAllProjects, fetchByTicketId])
|
}, [ticketId, fetchAllProjects, fetchByTicketId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -156,16 +162,15 @@ export default function TicketProjects({ ticket }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card pad ticket-projects">
|
<Card title={`Projekte (${assigned.length})`}>
|
||||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
|
{importing && <p className="muted">Suche neue Repos …</p>}
|
||||||
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>}
|
{importHint && <p className="msg err" role="alert">{importHint}</p>}
|
||||||
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>}
|
{error && <p className="msg err" role="alert">{error}</p>}
|
||||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
|
||||||
{assigned.length === 0
|
{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) => (
|
: assigned.map((p) => (
|
||||||
<div key={p.$id}>
|
<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
|
<AssignedProjectCard
|
||||||
project={p}
|
project={p}
|
||||||
showActions
|
showActions
|
||||||
@@ -177,7 +182,7 @@ export default function TicketProjects({ ticket }) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="ticket-projects-assign">
|
<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>
|
<option value="">Projekt wählen …</option>
|
||||||
{available.map((p) => (
|
{available.map((p) => (
|
||||||
<option key={p.$id} value={p.$id}>
|
<option key={p.$id} value={p.$id}>
|
||||||
@@ -186,44 +191,43 @@ export default function TicketProjects({ ticket }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
|
<Button type="button" disabled={!selected || busy} onClick={assign}>Zuweisen</Button>
|
||||||
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
|
<Button variant="quiet" type="button" onClick={() => setShowCreate((s) => !s)}>Neues Projekt</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assignResult && (
|
{assignResult && (
|
||||||
<div style={{ marginTop: 8, fontSize: 13 }}>
|
<p className="msg">
|
||||||
<div>{assignResult.text}</div>
|
{assignResult.text}
|
||||||
{assignResult.warnings.map((w, i) => (
|
{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 && (
|
{showCreate && (
|
||||||
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
|
<form className="ticket-projects-assign" onSubmit={create}>
|
||||||
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
|
<input className="input" aria-label="Anzeigename" 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 />
|
<input className="input" aria-label="Subdomain" 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>
|
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{kindDialog && (
|
{kindDialog && (
|
||||||
<div className="overlay">
|
<Dialog title="Was ist das?" onClose={() => setKindDialog(false)}>
|
||||||
<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>
|
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
|
||||||
{kinds.length === 0 && (
|
{kinds.length === 0 && (
|
||||||
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div>
|
<p className="msg err">Arten konnten nicht geladen werden – bitte neu laden.</p>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div className="row-buttons">
|
||||||
{kinds.map((k) => (
|
{kinds.map((k) => (
|
||||||
<button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button>
|
<Button key={k.key} type="button" onClick={() => assignWithKind(k.key)}>{k.label}</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dialog>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FaRotateRight } from 'react-icons/fa6'
|
|
||||||
import { Button } from '../ui'
|
import { Button } from '../ui'
|
||||||
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
|
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
|
||||||
import { loadChatComposer } from '../../lib/loadChatComposer'
|
import { loadChatComposer } from '../../lib/loadChatComposer'
|
||||||
@@ -119,8 +118,8 @@ export default function ChatThread({ customerId, customerName }) {
|
|||||||
<div className="woms-chat" ref={zoneRef}>
|
<div className="woms-chat" ref={zoneRef}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 wrap">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<span className="muted">{error}</span>
|
<span className="msg err" role="alert">{error}</span>
|
||||||
<Button variant="ghost" onClick={() => setReloadKey((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
<Button onClick={() => setReloadKey((k) => k + 1)}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="chat-messages" ref={boxRef} aria-live="polite" />
|
<div className="chat-messages" ref={boxRef} aria-live="polite" />
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
import { Card, Badge, Button, Field } from '../ui'
|
||||||
import { Card, Badge, Button } from '../ui'
|
|
||||||
import KundenansichtButton from '../customer/KundenansichtButton'
|
import KundenansichtButton from '../customer/KundenansichtButton'
|
||||||
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
|
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 { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
|
||||||
import { storageLabel } from '../../lib/storageFormat'
|
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 staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
|
||||||
const toCents = (value) => Math.round(Number(value) * 100)
|
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 [calcText, setCalcText] = useState('')
|
||||||
const [actionError, setActionError] = useState('')
|
const [actionError, setActionError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [busyAction, setBusyAction] = useState('')
|
||||||
const busyRef = useRef(false)
|
const busyRef = useRef(false)
|
||||||
const amountTouched = useRef(false)
|
const amountTouched = useRef(false)
|
||||||
const calcTimer = useRef(null)
|
const calcTimer = useRef(null)
|
||||||
@@ -85,6 +83,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
if (busyRef.current) return
|
if (busyRef.current) return
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setBusyAction(action)
|
||||||
try {
|
try {
|
||||||
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
|
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
@@ -94,6 +93,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
} finally {
|
} finally {
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
setBusyAction('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +101,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
if (busyRef.current) return
|
if (busyRef.current) return
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setBusyAction('apply')
|
||||||
try {
|
try {
|
||||||
const result = await applyCredit(id)
|
const result = await applyCredit(id)
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
@@ -110,11 +111,12 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
} finally {
|
} finally {
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
setBusyAction('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) return <Card><div className="area-error">{error}</div><Button variant="ghost" onClick={onClose}>Schließen</Button></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 <Card><FaSpinner className="spinner" /></Card>
|
if (!d || !form) return <div className="loading">Wird geladen …</div>
|
||||||
|
|
||||||
const r = d.request
|
const r = d.request
|
||||||
const c = d.customer
|
const c = d.customer
|
||||||
@@ -124,58 +126,59 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
|
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
|
||||||
const allChecked = Object.values(form.checks).every(Boolean)
|
const allChecked = Object.values(form.checks).every(Boolean)
|
||||||
const canApprove = allChecked && Number(form.amount) > 0 && !busy
|
const canApprove = allChecked && Number(form.amount) > 0 && !busy
|
||||||
|
const errorLine = actionError ? <p className="msg err" role="alert">{actionError}</p> : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card title={`Antrag ${r.ref} · ${c.name || c.companyName}`} actions={<Button onClick={onClose}>Schließen</Button>}>
|
||||||
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
<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>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>Antrag {r.ref} · {c.name || c.companyName}</h3>
|
<Card title="Antrag">
|
||||||
<span className="area-muted">eingegangen {fmtDateTime(r.createdAt)}</span> <Badge tone={st.tone}>{st.label}</Badge>
|
<dl className="kv">
|
||||||
</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>Leistung</dt><dd>{r.services}</dd>
|
||||||
<dt>Bisher bei</dt><dd>{r.provider}</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>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>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>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
|
||||||
<dt>Antwort an</dt><dd>{r.email}</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>
|
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<h4>Grund / Nachricht</h4>
|
</Card>
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{r.message}</p>
|
<Card title="Grund / Nachricht">
|
||||||
<h4>Nachweise</h4>
|
<p className="prose">{r.message}</p>
|
||||||
|
</Card>
|
||||||
|
<Card title="Nachweise">
|
||||||
{r.files.length ? (
|
{r.files.length ? (
|
||||||
<ul className="credit-files">
|
r.files.map((f) => (
|
||||||
{r.files.map((f) => (
|
<div key={f.id} className="list-row">
|
||||||
<li key={f.id}>
|
<div className="list-row-text">
|
||||||
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="area-muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
|
<a className="link" href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
|
||||||
</li>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
))
|
||||||
) : <p className="area-muted">Keine Nachweise (mehr) vorhanden.</p>}
|
) : <p className="muted">Keine Nachweise (mehr) vorhanden.</p>}
|
||||||
<h4>Verlauf</h4>
|
</Card>
|
||||||
|
<Card title="Verlauf">
|
||||||
{r.history.map((h, i) => (
|
{r.history.map((h, i) => (
|
||||||
<div key={i} className="credit-history">
|
<div key={i} className="list-row">
|
||||||
<strong>{historyLabel(h.type)}</strong> <span className="area-muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
|
<div className="list-row-text">
|
||||||
{h.text && <div style={{ whiteSpace: 'pre-wrap' }}>{h.text}</div>}
|
<strong>{historyLabel(h.type)}</strong> <span className="muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
|
||||||
{(h.files || []).length > 0 && <div className="area-muted">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>Kunde & Vertrag</h4>
|
<Card title="Kunde & Vertrag">
|
||||||
<dl className="area-kv">
|
<dl className="kv">
|
||||||
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
|
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
|
||||||
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
|
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
|
||||||
<dt>Abo</dt>
|
<dt>Abo</dt>
|
||||||
<dd>
|
<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>}
|
{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>
|
</dd>
|
||||||
{s && (
|
{s && (
|
||||||
<>
|
<>
|
||||||
@@ -186,98 +189,113 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
<div className="area-inline" style={{ margin: '12px 0' }}>
|
<div className="toolbar">
|
||||||
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} variant="ghost" />
|
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} />
|
||||||
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer" variant="ghost"><FaArrowUpRightFromSquare /> In Stripe öffnen</Button>}
|
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer">In Stripe öffnen</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
{d.otherRequests.length > 0 && (
|
{d.otherRequests.length > 0 && (
|
||||||
<>
|
<Card title="Weitere Anträge des Kunden">
|
||||||
<h4>Weitere Anträge des Kunden</h4>
|
|
||||||
<ul className="credit-files">
|
|
||||||
{d.otherRequests.map((o) => {
|
{d.otherRequests.map((o) => {
|
||||||
const ost = creditStatus(o.status)
|
const ost = creditStatus(o.status)
|
||||||
return (
|
return (
|
||||||
<li key={o.id}>
|
<div key={o.id} className="list-row">
|
||||||
<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)}` : ''}
|
<div className="list-row-text">
|
||||||
</li>
|
<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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</Card>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<h4>Automatische Prüfung</h4>
|
<Card title="Automatische Prüfung">
|
||||||
<ul className="credit-checks">
|
|
||||||
{d.autoChecks.map((k) => (
|
{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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Card>
|
||||||
|
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<>
|
<>
|
||||||
<h4>Prüfung durch dich</h4>
|
<Card title="Prüfung durch dich">
|
||||||
<div className="credit-manual">
|
<div className="field">
|
||||||
{d.requiredChecks.map((k) => (
|
{d.requiredChecks.map((k) => (
|
||||||
<label key={k.key}>
|
<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}
|
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h4>Berechnung</h4>
|
</Card>
|
||||||
<div className="credit-calc">
|
<Card title="Berechnung">
|
||||||
<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>
|
<div className="form-stack">
|
||||||
<label>Rechnung gilt für
|
<div className="field-row">
|
||||||
<select id="calc-months" className="form-control" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
|
<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>)}
|
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</Field>
|
||||||
<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>
|
||||||
<div className="area-summary">{calcText}</div>
|
<div className="field-row">
|
||||||
<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>
|
<Field label="Bezahlt bis" htmlFor="calc-paid-until">
|
||||||
<label className="credit-field">Gutschrift (€)
|
<input id="calc-paid-until" className="input" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} />
|
||||||
<input id="credit-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.amount} style={{ maxWidth: 180 }}
|
</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 }) }} />
|
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
|
||||||
</label>
|
</Field>
|
||||||
<label className="credit-field">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
|
<Field label="Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)" htmlFor="credit-note">
|
||||||
<textarea id="credit-note" className="form-control" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
<textarea id="credit-note" className="textarea" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||||
</label>
|
</Field>
|
||||||
<div className="area-inline">
|
<div className="flex items-center gap-3 wrap">
|
||||||
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>Genehmigen & gutschreiben</Button>
|
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>{busyAction === 'approve' ? 'Speichert …' : 'Genehmigen & gutschreiben'}</Button>
|
||||||
<Button variant="ghost" onClick={() => decide('question')} disabled={busy}>Rückfrage senden</Button>
|
<Button onClick={() => decide('question')} disabled={busy}>{busyAction === 'question' ? 'Sendet …' : 'Rückfrage senden'}</Button>
|
||||||
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>Ablehnen</Button>
|
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>{busyAction === 'reject' ? 'Speichert …' : 'Ablehnen'}</Button>
|
||||||
{busy && <FaSpinner className="spinner" />}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="area-muted">
|
<p className="muted small">
|
||||||
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
|
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>}
|
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
|
||||||
</p>
|
</p>
|
||||||
|
{errorLine}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Card title="Entscheidung">
|
||||||
<h4>Entscheidung</h4>
|
|
||||||
{r.status === 'genehmigt' ? (
|
{r.status === 'genehmigt' ? (
|
||||||
<>
|
<>
|
||||||
<dl className="area-kv">
|
<dl className="kv">
|
||||||
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
||||||
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
|
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
|
||||||
<dt>Berechnung</dt><dd>{r.calculation}</dd>
|
<dt>Berechnung</dt><dd>{r.calculation}</dd>
|
||||||
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</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>
|
<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>
|
</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>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>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</>
|
{errorLine}
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
{actionError && <div className="area-error">{actionError}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FaSpinner } from 'react-icons/fa6'
|
import { Badge, EmptyState, Tabs } from '../ui'
|
||||||
import { Card, Badge, Button } from '../ui'
|
|
||||||
import { listCredits } from '../../lib/teamApi'
|
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 { 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
|
// Liste der Uebernahmegutschriften (Filter "Offen"/"Alle"), wie im frueheren Admin-Panel; optional nur ein Kunde
|
||||||
export default function CreditList({ customerId, selectedId, onSelect, reloadKey }) {
|
export default function CreditList({ customerId, selectedId, onSelect, reloadKey }) {
|
||||||
@@ -23,41 +21,52 @@ export default function CreditList({ customerId, selectedId, onSelect, reloadKey
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="area-filter seg">
|
<Tabs tabs={[{ id: 'open', label: 'Offen' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
|
||||||
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen</button>
|
{error && <div className="msg err" role="alert">{error}</div>}
|
||||||
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
|
{!rows && !error && <div className="loading">Wird geladen …</div>}
|
||||||
</div>
|
{rows && rows.length === 0 && (
|
||||||
<Card pad={false}>
|
<EmptyState title={filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'} />
|
||||||
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
|
)}
|
||||||
{!rows && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
|
{rows && rows.length > 0 && (
|
||||||
{rows && (
|
<table className="table table-wrap">
|
||||||
<div className="area-rows">
|
<thead>
|
||||||
<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>
|
<tr>
|
||||||
{rows.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</span></div>}
|
<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) => {
|
{rows.map((r) => {
|
||||||
const st = creditStatus(r.status)
|
const st = creditStatus(r.status)
|
||||||
|
const note = creditStatusNote(r)
|
||||||
return (
|
return (
|
||||||
<div key={r.id} className={`area-row credit-row ${r.id === selectedId ? 'active' : ''}`}>
|
<tr key={r.id} aria-current={r.id === selectedId ? 'true' : undefined}>
|
||||||
<span>{fmtDateTime(r.createdAt)}<br /><span className="area-muted">{r.ref}</span></span>
|
<td className="hide-sm">{fmtDateTime(r.createdAt)}<div className="cell-sub">{r.ref}</div></td>
|
||||||
<span><strong>{r.customerName}</strong>{r.customerCode && <><br /><span className="area-muted">Kd.-Nr. {r.customerCode}</span></>}</span>
|
<td>
|
||||||
<span>{r.services}<br /><span className="area-muted">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</span></span>
|
<div className="cell-title">{r.customerName}</div>
|
||||||
<span>{formatCents(r.invoiceAmountCents)}<br /><span className="area-muted">für {r.billingMonths} Mon.</span></span>
|
{r.customerCode && <div className="cell-sub">Kd.-Nr. {r.customerCode}</div>}
|
||||||
<span>{fmtDate(r.paidUntil)}</span>
|
</td>
|
||||||
<span>
|
<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>
|
<Badge tone={st.tone}>{st.label}</Badge>
|
||||||
{r.status === 'genehmigt' && <><br /><span className="area-muted">{formatCents(r.amountCents)}{r.appliedAt ? '' : ' · nicht gebucht'}</span></>}
|
{note && <div className="cell-sub">{note}</div>}
|
||||||
</span>
|
</td>
|
||||||
<span>
|
<td className="nowrap">
|
||||||
<Button size="sm" variant="primary" onClick={() => onSelect(r.id)}>
|
<button type="button" className="link" onClick={() => onSelect(r.id)}>{creditActionLabel(r.status)}</button>
|
||||||
{['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
|
</td>
|
||||||
</Button>
|
</tr>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</tbody>
|
||||||
|
</table>
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -23,11 +23,11 @@ export default function CreditsView({ customerId }) {
|
|||||||
return (
|
return (
|
||||||
<div className="credits-view">
|
<div className="credits-view">
|
||||||
{selected && (
|
{selected && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
|
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className={selected ? 'mt-3' : undefined}>
|
||||||
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
|
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Card, Badge } from '../ui'
|
import { Card, Badge } from '../ui'
|
||||||
import { getCustomerAboStatus } from '../../lib/teamApi'
|
import { getCustomerAboStatus } from '../../lib/teamApi'
|
||||||
import { fmtDate, productLabelOf } from '../../lib/format'
|
import { fmtDate, productLabelOf } from '../../lib/format'
|
||||||
import { storageLabel } from '../../lib/storageFormat'
|
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
|
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
|
||||||
export default function AboStatusCard({ customerId }) {
|
export default function AboStatusCard({ customerId }) {
|
||||||
@@ -23,13 +21,13 @@ export default function AboStatusCard({ customerId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Abo">
|
<Card title="Abo">
|
||||||
{error && <div className="area-error">{error}</div>}
|
{error && <div className="msg err" role="alert">{error}</div>}
|
||||||
{!abo && !error && <FaSpinner className="spinner" />}
|
{!abo && !error && <div className="loading">Wird geladen …</div>}
|
||||||
{abo && !abo.status && <span className="area-muted">kein Abo</span>}
|
{abo && !abo.status && <p className="muted">kein Abo</p>}
|
||||||
{abo && abo.status && (
|
{abo && abo.status && (
|
||||||
<dl className="area-kv">
|
<dl className="kv">
|
||||||
<dt>Abo</dt>
|
<dt>Abo</dt>
|
||||||
<dd className="area-inline">
|
<dd className="flex items-center gap-2 wrap">
|
||||||
{productLabelOf(abo.productKey)}
|
{productLabelOf(abo.productKey)}
|
||||||
<Badge tone={abo.active ? 'ok' : 'danger'}>{abo.active ? 'aktiv' : abo.status}</Badge>
|
<Badge tone={abo.active ? 'ok' : 'danger'}>{abo.active ? 'aktiv' : abo.status}</Badge>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Card, Badge, Button, EmptyState } from '../ui'
|
import { Card, Badge, Button, EmptyState } from '../ui'
|
||||||
import { copyText } from '../CopyableCredential'
|
import { copyText } from '../CopyableCredential'
|
||||||
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
|
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
|
||||||
@@ -11,9 +10,9 @@ import {
|
|||||||
} from '../../lib/funktionenView'
|
} from '../../lib/funktionenView'
|
||||||
import { CHANNELS, channelLabel } from '../../lib/analyseView'
|
import { CHANNELS, channelLabel } from '../../lib/analyseView'
|
||||||
import { berlinYmd, fmtDateTime } from '../../lib/format'
|
import { berlinYmd, fmtDateTime } from '../../lib/format'
|
||||||
|
import { ticketPath } from '../../lib/ticketRoutes'
|
||||||
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
|
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
|
||||||
import ZeitenPanel from './ZeitenPanel'
|
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")
|
// 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)
|
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
|
||||||
@@ -30,13 +29,13 @@ function SiteKey({ value }) {
|
|||||||
timer.current = setTimeout(() => setCopied(false), 1800)
|
timer.current = setTimeout(() => setCopied(false), 1800)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="funktionen-sitekey">
|
<div className="funktionen-sitekey">
|
||||||
<span className="area-muted">Site-Key</span>
|
<span className="muted">Site-Key</span>
|
||||||
<code>{value}</code>
|
<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'}>
|
<button type="button" className="link" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
|
||||||
{copied ? <FaCheck className="funktionen-sitekey-ok" /> : <FaCopy />}
|
{copied ? 'Kopiert' : 'Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +51,8 @@ function KopierKnopf({ text, label }) {
|
|||||||
timer.current = setTimeout(() => setCopied(false), 1800)
|
timer.current = setTimeout(() => setCopied(false), 1800)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button size="sm" variant="ghost" onClick={copy} title={label}>
|
<Button type="button" onClick={copy}>
|
||||||
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>}
|
{copied ? 'Kopiert' : label}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,31 +77,31 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
|
|||||||
const hindernis = hindernisText(feature.hindernis)
|
const hindernis = hindernisText(feature.hindernis)
|
||||||
if (fehler || !antwort) {
|
if (fehler || !antwort) {
|
||||||
return (
|
return (
|
||||||
<div className="area-row funktionen-panel">
|
<div className="funktionen-panel">
|
||||||
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />}
|
{fehler ? <div className="msg err" role="alert">{fehler}</div> : <div className="muted">Wird geladen …</div>}
|
||||||
{hindernis && <div className="area-summary warn">{hindernis}</div>}
|
{hindernis && <div className="msg warn">{hindernis}</div>}
|
||||||
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>}
|
{fehler && <div><Button type="button" onClick={() => setRetry((n) => n + 1)}>Erneut laden</Button></div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const modell = panelModell(antwort, feature)
|
const modell = panelModell(antwort, feature)
|
||||||
return (
|
return (
|
||||||
<div className="area-row funktionen-panel">
|
<div className="funktionen-panel">
|
||||||
<div className="funktionen-panel-head">
|
<div className="funktionen-panel-head">
|
||||||
<strong>Einbauanleitung</strong>
|
<strong>Einbauanleitung</strong>
|
||||||
{modell.kopf && <span className="area-muted">{modell.kopf}</span>}
|
{modell.kopf && <span className="muted small">{modell.kopf}</span>}
|
||||||
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>}
|
{antwort.ticketWoid && <Link className="link" to={ticketPath(antwort.ticketWoid)}>Zum Ticket</Link>}
|
||||||
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button>
|
<button type="button" className="link" onClick={() => setRetry((n) => n + 1)}>Anleitung neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
{modell.warnungen.length > 0 && (
|
{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>)}
|
{modell.warnungen.map((text, i) => <li key={i}>{text}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>}
|
{modell.hinweis && <div className="msg warn">{modell.hinweis}</div>}
|
||||||
{modell.kopien.length > 0 && (
|
{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} />)}
|
{modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -122,26 +121,26 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
|
|||||||
const ansicht = textformAnsicht(feld, funktion)
|
const ansicht = textformAnsicht(feld, funktion)
|
||||||
const { label, links, vorlage } = ansicht
|
const { label, links, vorlage } = ansicht
|
||||||
return (
|
return (
|
||||||
<div className="area-row funktionen-panel">
|
<div className="funktionen-panel">
|
||||||
<label className="area-inline" style={{ alignItems: 'flex-start', flexWrap: 'nowrap' }}>
|
<label className="funktionen-zustimmung">
|
||||||
<input type="checkbox" style={{ marginTop: 3 }} checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
|
<input type="checkbox" checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
|
||||||
<span>
|
<span>
|
||||||
{label.before}
|
{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}
|
{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}
|
{label.after}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="area-inline">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<label>
|
<label className="flex items-center gap-2 wrap">
|
||||||
Datum der Zustimmung:{' '}
|
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 })} />
|
<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>
|
</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>
|
<span>Weg:</span>
|
||||||
{CHANNELS.map((c) => (
|
{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 })} />
|
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
|
||||||
{channelLabel(c)}
|
{channelLabel(c)}
|
||||||
</label>
|
</label>
|
||||||
@@ -149,13 +148,13 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{vorlage ? (
|
{vorlage ? (
|
||||||
<details>
|
<details className="funktionen-vorlage">
|
||||||
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
|
<summary>Textvorlage für den Chat</summary>
|
||||||
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{vorlage}</p>
|
<p>{vorlage}</p>
|
||||||
<KopierKnopf text={vorlage} label="Vorlage kopieren" />
|
<KopierKnopf text={vorlage} label="Vorlage kopieren" />
|
||||||
</details>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -190,7 +189,7 @@ function ShopPanel({ customerId, shop, onDecided }) {
|
|||||||
}
|
}
|
||||||
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
|
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
|
||||||
return (
|
return (
|
||||||
<div className="area-row funktionen-panel">
|
<div className="funktionen-shop">
|
||||||
<div className="funktionen-panel-head">
|
<div className="funktionen-panel-head">
|
||||||
<strong>Webshop: {shop.projectName}</strong>
|
<strong>Webshop: {shop.projectName}</strong>
|
||||||
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
|
{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>
|
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{a && (
|
{a && (
|
||||||
<dl className="area-kv">
|
<dl className="kv">
|
||||||
{anfrageZeilen(a.anfrage).map((z) => (
|
{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>
|
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
|
||||||
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</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>
|
</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' && (
|
{a?.status === 'offen' && (
|
||||||
frage ? (
|
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>
|
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
|
||||||
{frage === 'ablehnen' && (
|
{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 type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
|
<Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="area-inline">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
|
<Button type="button" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
|
<Button type="button" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -296,15 +295,26 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
|
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) }))
|
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
|
||||||
|
|
||||||
if (error) return <Card><div className="area-error">{error}</div></Card>
|
if (error) return <div className="msg err" role="alert">{error}</div>
|
||||||
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
|
if (!projects) return <div className="loading">Wird geladen …</div>
|
||||||
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
|
if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
|
||||||
|
|
||||||
|
const shopEintraege = shop.filter(shopHatEintrag)
|
||||||
return (
|
return (
|
||||||
<Card title="Funktionen" pad={false}>
|
<Card title="Funktionen">
|
||||||
<div className="area-rows">
|
<table className="table funktionen-table">
|
||||||
<div className="area-row funktionen-row head"><span>Projekt</span><span>Funktion</span><span>Status</span><span>Ticket</span><span>Aktionen</span></div>
|
<thead>
|
||||||
{projects.flatMap((p) => FUNKTIONEN.map((f, index) => {
|
<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 feature = funktionOf(p, f.key)
|
||||||
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
|
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
|
||||||
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
||||||
@@ -312,81 +322,95 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
||||||
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
|
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
|
||||||
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, 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 hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
|
||||||
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
|
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 (
|
return (
|
||||||
<Fragment key={`${p.projectId}:${f.key}`}>
|
<Fragment key={f.key}>
|
||||||
<div className="area-row funktionen-row">
|
<tr className={`funktionen-zeile${offen(-1) ? ' funktionen-offen' : ''}`}>
|
||||||
{index === 0 ? (
|
<td>{f.label}</td>
|
||||||
<span>
|
<td>
|
||||||
<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>
|
<Badge tone={tone}>{label}</Badge>
|
||||||
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
|
{hindernis && <div className="cell-sub msg warn">{hindernis}</div>}
|
||||||
{zeitenText && <><br /><span className={feature.hindernis ? 'area-warn' : 'area-muted'}>{zeitenText}</span></>}
|
{zeitenText && <div className={feature.hindernis ? 'cell-sub msg warn' : 'cell-sub'}>{zeitenText}</div>}
|
||||||
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
|
{abgeschaltet && <div className="cell-sub">{abgeschaltet}</div>}
|
||||||
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
|
{feature.adminNote && <div className="cell-sub" title={feature.adminNote}>{short(feature.adminNote)}</div>}
|
||||||
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
|
{siteKey && <SiteKey value={siteKey} />}
|
||||||
</span>
|
</td>
|
||||||
<span>
|
<td className={feature.ticketMissing || feature.ticketWoid ? undefined : 'funktionen-ohne-ticket'}>
|
||||||
{feature.ticketMissing
|
{feature.ticketMissing
|
||||||
? <Badge tone="danger">Ticket fehlt</Badge>
|
? <Badge tone="danger">Ticket fehlt</Badge>
|
||||||
: feature.ticketWoid
|
: feature.ticketWoid
|
||||||
? <Link to={`/tickets?woid=${encodeURIComponent(feature.ticketWoid)}`}>#{feature.ticketWoid}</Link>
|
? <Link className="link" to={ticketPath(feature.ticketWoid)}>#{feature.ticketWoid}</Link>
|
||||||
: '–'}
|
: <span className="faint">–</span>}
|
||||||
</span>
|
</td>
|
||||||
<span className="area-inline">
|
<td>
|
||||||
{isConfirming ? (
|
{!isConfirming && (
|
||||||
<Fragment>
|
<div className="funktionen-actions">
|
||||||
<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) => (
|
{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)}>
|
<button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
|
||||||
{AKTION_LABEL[action]}
|
{AKTION_LABEL[action]}
|
||||||
</Button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{hatAnleitung && (
|
{hatAnleitung && (
|
||||||
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
|
<button type="button" className="link" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
|
||||||
Anleitung
|
Anleitung
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{braucheTextform && (
|
|
||||||
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
|
|
||||||
)}
|
)}
|
||||||
{hatZeitenPanel && (
|
</td>
|
||||||
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
|
</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>
|
||||||
|
)}
|
||||||
|
{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 && (
|
{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} />
|
<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>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}))}
|
})}
|
||||||
</div>
|
</tbody>
|
||||||
{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>}
|
</table>
|
||||||
{shop.filter(shopHatEintrag).length > 0 && (
|
{status && <div className={`msg ${status.ok ? 'ok' : 'err'} funktionen-msg`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
|
||||||
<div className="area-rows">
|
{shopFehler && <div className="msg err funktionen-msg" role="alert">{shopFehler}</div>}
|
||||||
{shop.filter(shopHatEintrag).map((s) => (
|
{shopEintraege.map((s) => (
|
||||||
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
|
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
|
import { Button, EmptyState } from '../ui'
|
||||||
import { Card, Button, EmptyState } from '../ui'
|
|
||||||
import { getCustomerViewLog } from '../../lib/teamApi'
|
import { getCustomerViewLog } from '../../lib/teamApi'
|
||||||
import { formatViewLogTime, viewLogLabel } from '../../lib/viewLog'
|
import { formatViewLogTime, viewLogLabel } from '../../lib/viewLog'
|
||||||
|
|
||||||
@@ -24,33 +23,38 @@ export default function CustomerViewLog({ customerId }) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
|
||||||
<div className="flex items-center gap-2 wrap">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<span className="muted">{error}</span>
|
<div className="msg err" role="alert">{error}</div>
|
||||||
<Button variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
|
<Button onClick={load}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!entries) return <Card><FaSpinner className="spinner" /></Card>
|
if (!entries) return <div className="loading">Wird geladen …</div>
|
||||||
if (entries.length === 0) {
|
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 (
|
return (
|
||||||
<Card pad={false}>
|
<table className="table">
|
||||||
<div className="list">
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Mitarbeiter</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<div key={e.id} className="list-row">
|
<tr key={e.id}>
|
||||||
<span className="mono muted list-row-text" style={{ minWidth: 130 }}>{formatViewLogTime(e.at)}</span>
|
<td className="nowrap num">{formatViewLogTime(e.at)}</td>
|
||||||
<span className="list-row-text" style={{ minWidth: 120, fontWeight: 600 }}>{e.staffName || '–'}</span>
|
<td>{e.staffName || '–'}</td>
|
||||||
<div className="grow list-row-text">
|
<td>
|
||||||
<div>{viewLogLabel(e.action)}</div>
|
<div>{viewLogLabel(e.action)}</div>
|
||||||
{e.detail && <div className="muted">{e.detail}</div>}
|
{e.detail && <div className="cell-sub">{e.detail}</div>}
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</div>
|
</tbody>
|
||||||
</Card>
|
</table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaArrowUpRightFromSquare, FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Button } from '../ui'
|
import { Button } from '../ui'
|
||||||
import { createKundenansichtLink } from '../../lib/teamApi'
|
import { createKundenansichtLink } from '../../lib/teamApi'
|
||||||
import { openInNewTab } from '../../lib/openInNewTab'
|
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.
|
* 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).
|
* 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 [busy, setBusy] = useState(false)
|
||||||
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ export default function KundenansichtButton({ customer, variant = 'primary', che
|
|||||||
disabled={!hasAccess || busy}
|
disabled={!hasAccess || busy}
|
||||||
title={hasAccess ? 'Portal so öffnen, wie der Kunde es sieht' : 'Kein Portal-Zugang'}
|
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>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FaCopy, FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Button } from '../ui'
|
import { Button } from '../ui'
|
||||||
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
|
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
|
||||||
import {
|
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)
|
// 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.
|
// 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
|
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (funktionen-panel) steht in einer eigenen Tabellenzeile
|
||||||
// des Fünf-Spalten-Rasters, wie die Einbauanleitung des Terminplaners.
|
// 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 }) {
|
export default function ZeitenPanel({ customerId, projectId, feature, onSaved, onReload }) {
|
||||||
const start = teamFormular(feature)
|
const start = teamFormular(feature)
|
||||||
const [form, setForm] = useState(start)
|
const [form, setForm] = useState(start)
|
||||||
@@ -62,17 +61,17 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
|
|||||||
const knoepfe = ersatzKnoepfe(feature)
|
const knoepfe = ersatzKnoepfe(feature)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="area-row funktionen-panel funktionen-panel-zeiten">
|
<div className="funktionen-panel funktionen-panel-zeiten">
|
||||||
<div className="funktionen-panel-head">
|
<div className="funktionen-panel-head">
|
||||||
<strong>Öffnungszeiten live</strong>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={speichern} className="funktionen-panel-form">
|
<form onSubmit={speichern} className="funktionen-panel-form">
|
||||||
<label className="funktionen-panel-feld">
|
<label className="funktionen-panel-feld">
|
||||||
<span>Header-Selektor</span>
|
<span>Header-Selektor</span>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="input"
|
||||||
value={form.navSelektor}
|
value={form.navSelektor}
|
||||||
onChange={(e) => aendern({ navSelektor: e.target.value })}
|
onChange={(e) => aendern({ navSelektor: e.target.value })}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -82,51 +81,51 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
|
|||||||
aria-invalid={selektorFehler ? 'true' : 'false'}
|
aria-invalid={selektorFehler ? 'true' : 'false'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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.
|
Nur nötig, wenn die Hinweis-Leiste den festen Header der Website überlappt: CSS-Selektor des Headers.
|
||||||
</div>
|
</div>
|
||||||
{selektorFehler && <div className="area-error" role="alert">{selektorFehler}</div>}
|
{selektorFehler && <div className="msg err" role="alert">{selektorFehler}</div>}
|
||||||
<div className="area-inline">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<label className="area-inline">
|
<label className="check">
|
||||||
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
|
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
|
||||||
Kontakt-Leiste aus
|
Kontakt-Leiste aus
|
||||||
</label>
|
</label>
|
||||||
<label className="area-inline">
|
<label className="check">
|
||||||
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
|
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
|
||||||
Hinweis-Leiste aus
|
Hinweis-Leiste aus
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="area-inline">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<Button type="submit" size="sm" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
|
<Button type="submit" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
|
||||||
{busy ? <FaSpinner className="spinner" /> : 'Speichern'}
|
{busy ? 'Speichert …' : 'Speichern'}
|
||||||
</Button>
|
</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 && (
|
{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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="funktionen-panel-head">
|
<div className="funktionen-panel-head">
|
||||||
<strong>Ersatz-HTML</strong>
|
<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>
|
</div>
|
||||||
{knoepfe.length === 0 ? (
|
{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) => (
|
{knoepfe.map((k) => (
|
||||||
<Button key={k.kurz} type="button" size="sm" variant="ghost" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
|
<Button key={k.kurz} type="button" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
|
||||||
{ersatz.busy && ersatz.kurz === k.kurz ? <FaSpinner className="spinner" /> : <FaCopy />} {k.label}
|
{ersatz.busy && ersatz.kurz === k.kurz ? 'Lädt …' : k.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{ersatz.html && (
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
className="form-control funktionen-panel-html"
|
className="textarea funktionen-panel-html"
|
||||||
rows={8}
|
rows={8}
|
||||||
value={ersatz.html}
|
value={ersatz.html}
|
||||||
aria-label="Ersatz-HTML zum Kopieren"
|
aria-label="Ersatz-HTML zum Kopieren"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FaTriangleExclamation } from 'react-icons/fa6'
|
import { Badge, Card, EmptyState, Stat } from '../ui'
|
||||||
import { Badge, Card, EmptyState } from '../ui'
|
|
||||||
import { aboView } from '../../lib/financeView'
|
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
|
* Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
|
||||||
@@ -12,29 +12,23 @@ export default function AboCard({ abos }) {
|
|||||||
|
|
||||||
if (v.error) {
|
if (v.error) {
|
||||||
return (
|
return (
|
||||||
<Card title="Abos" className="fin-abo-card">
|
<Card title="Abos">
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">
|
<div className="fin-body">
|
||||||
<FaTriangleExclamation aria-hidden="true" /> {v.error}
|
<p className="msg warn" role="alert">
|
||||||
|
{v.error}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Abos" className="fin-abo-card" bodyStyle={{ padding: 0 }}>
|
<Card title="Abos">
|
||||||
<div className="fin-stats">
|
<div className="fin-body">
|
||||||
<span className="fin-amount fin-stat-value">{v.monthlyText}</span>
|
<div className="stats fin-abo-stats">
|
||||||
<span className="fin-stat-label">Abo-Einnahmen pro Monat</span>
|
<Stat label="Abo-Einnahmen pro Monat" value={v.monthlyText} tone={isNegText(v.monthlyText) ? 'neg' : ''} />
|
||||||
<span className="fin-amount fin-stat-value">{v.payingText}</span>
|
<Stat label="zahlende Kunden" value={v.payingText} />
|
||||||
<span className="fin-stat-label">zahlende Kunden</span>
|
{v.atRiskText && <Stat label="gefährdet" value={v.atRiskText} tone="neg" />}
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{v.rows.length === 0 ? (
|
{v.rows.length === 0 ? (
|
||||||
@@ -47,7 +41,7 @@ export default function AboCard({ abos }) {
|
|||||||
<span className="fin-abo-customer">{row.customer}</span>
|
<span className="fin-abo-customer">{row.customer}</span>
|
||||||
<span className="fin-abo-product muted"> · {row.product}</span>
|
<span className="fin-abo-product muted"> · {row.product}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="fin-amount fin-abo-amount">{row.amountText}</div>
|
<div className={`fin-amount fin-abo-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}>{row.amountText}</div>
|
||||||
<div className="fin-abo-meta">
|
<div className="fin-abo-meta">
|
||||||
<Badge tone={row.tone}>{row.statusLabel}</Badge>
|
<Badge tone={row.tone}>{row.statusLabel}</Badge>
|
||||||
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
|
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
|
||||||
@@ -56,6 +50,7 @@ export default function AboCard({ abos }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</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
|
* 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
|
* Ü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.
|
* `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' }) {
|
export default function AddressWarning({ customer, onShowOverview, variant = 'page' }) {
|
||||||
if (!customer || addressComplete(customer)) return null
|
if (!customer || addressComplete(customer)) return null
|
||||||
return (
|
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>
|
<span>{ADDRESS_INCOMPLETE_TEXT}</span>
|
||||||
{onShowOverview ? (
|
{onShowOverview ? (
|
||||||
<button type="button" className="fin-link" onClick={onShowOverview}>
|
<button type="button" className="link" onClick={onShowOverview}>
|
||||||
Zur Übersicht
|
Zur Übersicht
|
||||||
</button>
|
</button>
|
||||||
) : customer.$id ? (
|
) : customer.$id ? (
|
||||||
<Link className="fin-link" to={`/customers/${customer.$id}`}>
|
<Link className="link" to={`/customers/${customer.$id}`}>
|
||||||
Zur Übersicht
|
Zur Übersicht
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaXmark } from 'react-icons/fa6'
|
import { Badge, Button, Dialog, Field } from '../ui'
|
||||||
import { Badge, Button } from '../ui'
|
|
||||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
|
||||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
|
||||||
import {
|
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
|
* 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
|
* 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)
|
* 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 =
|
* 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).
|
* 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 lockRef = useRef(false)
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
const dirtyRef = useRef(false)
|
const dirtyRef = useRef(false)
|
||||||
const dialogRef = useRef(null)
|
|
||||||
const confirmRef = useRef(null)
|
const confirmRef = useRef(null)
|
||||||
const errorRef = useRef(null)
|
const errorRef = useRef(null)
|
||||||
|
|
||||||
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
|
||||||
const unassigned = !tx.category
|
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
|
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
|
||||||
useScrollIntoView(errorRef, error)
|
useScrollIntoView(errorRef, error)
|
||||||
|
|
||||||
@@ -82,15 +79,6 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
onClose({ refresh: dirtyRef.current })
|
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) {
|
function choose(key) {
|
||||||
setSelected(key)
|
setSelected(key)
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
@@ -154,7 +142,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
|
const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
|
||||||
|
|
||||||
const radio = (o) => (
|
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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="fin-assign-target"
|
name="fin-assign-target"
|
||||||
@@ -166,41 +154,68 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
<span className="fin-choice-text">
|
<span className="fin-choice-text">
|
||||||
<span className="fin-choice-label">{o.label}</span>
|
<span className="fin-choice-label">{o.label}</span>
|
||||||
{(o.confidence || o.detail) && (
|
{(o.confidence || o.detail) && (
|
||||||
<span className="fin-choice-detail muted">
|
<span className="fin-choice-detail">
|
||||||
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
|
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
|
||||||
{o.confidence && o.detail ? ' · ' : ''}
|
{o.confidence && o.detail ? ' · ' : ''}
|
||||||
{o.detail}
|
{o.detail}
|
||||||
</span>
|
</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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
let footer
|
||||||
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
|
if (model.assigned) {
|
||||||
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
|
footer = (
|
||||||
<FaXmark aria-hidden="true" />
|
<div className="fin-foot">
|
||||||
</button>
|
<Button type="button" onClick={close} disabled={disabled}>Schließen</Button>
|
||||||
<div
|
{model.canUnassign && (
|
||||||
className="overlay-content fin-dialog fin-assign"
|
<Button type="button" onClick={doUnassign} disabled={disabled}>
|
||||||
role="dialog"
|
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
|
||||||
aria-modal="true"
|
</Button>
|
||||||
aria-labelledby="fin-assign-title"
|
)}
|
||||||
tabIndex={-1}
|
</div>
|
||||||
ref={dialogRef}
|
)
|
||||||
>
|
} else if (confirming && option) {
|
||||||
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<dl className="fin-tx-facts">
|
return (
|
||||||
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
|
<Dialog title="Buchung zuordnen" onClose={close} busy={disabled} footer={footer}>
|
||||||
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
|
<dl className="kv">
|
||||||
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
|
<dt>Datum</dt>
|
||||||
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
|
<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>
|
</dl>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -211,41 +226,32 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
Zugeordnet: <strong>{model.currentText}</strong>
|
Zugeordnet: <strong>{model.currentText}</strong>
|
||||||
</p>
|
</p>
|
||||||
{!model.canUnassign && (
|
{!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>
|
<p className="msg muted">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>}
|
{model.hint && <p className="msg muted" role="note">{model.hint}</p>}
|
||||||
|
|
||||||
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
|
{showInvoiceState && invoicesLoading && <p className="msg muted" role="status">Offene Rechnungen werden geladen …</p>}
|
||||||
{showInvoiceState && invoicesError && (
|
{showInvoiceState && invoicesError && (
|
||||||
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
|
<p className="msg 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.loading && <p className="msg muted" 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>}
|
{outgoing && fixed.error && <p className="msg warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
|
||||||
|
|
||||||
{model.suggestions.length > 0 && (
|
{model.suggestions.length > 0 && (
|
||||||
<fieldset className="fin-choices">
|
<fieldset className="fin-choices">
|
||||||
<legend className="form-label">Vorschläge</legend>
|
<legend>Vorschläge</legend>
|
||||||
{model.suggestions.map(radio)}
|
{model.suggestions.map(radio)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{model.invoices.length > 0 && (
|
{model.invoices.length > 0 && (
|
||||||
<div className="form-group">
|
<Field label="Andere offene Rechnung" htmlFor="fin-assign-invoice">
|
||||||
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
|
|
||||||
<select
|
<select
|
||||||
id="fin-assign-invoice"
|
id="fin-assign-invoice"
|
||||||
className="form-control"
|
className="select"
|
||||||
value={invoiceSelectValue}
|
value={invoiceSelectValue}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => choose(e.target.value)}
|
onChange={(e) => choose(e.target.value)}
|
||||||
@@ -257,15 +263,14 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{model.fixedCosts.length > 0 && (
|
{model.fixedCosts.length > 0 && (
|
||||||
<div className="form-group">
|
<Field label="Fixkosten" htmlFor="fin-assign-fixed">
|
||||||
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
|
|
||||||
<select
|
<select
|
||||||
id="fin-assign-fixed"
|
id="fin-assign-fixed"
|
||||||
className="form-control"
|
className="select"
|
||||||
value={fixedSelectValue}
|
value={fixedSelectValue}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => choose(e.target.value)}
|
onChange={(e) => choose(e.target.value)}
|
||||||
@@ -275,47 +280,32 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
|
|||||||
<option key={o.key} value={o.key}>{o.label}</option>
|
<option key={o.key} value={o.key}>{o.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<fieldset className="fin-choices">
|
<fieldset className="fin-choices">
|
||||||
<legend className="form-label">Kategorie</legend>
|
<legend>Kategorie</legend>
|
||||||
{model.categories.map(radio)}
|
{model.categories.map(radio)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Notiz (optional)" htmlFor="fin-assign-note">
|
||||||
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="fin-assign-note"
|
id="fin-assign-note"
|
||||||
className="form-control"
|
className="textarea"
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={NOTE_MAX}
|
maxLength={NOTE_MAX}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{confirming && option ? (
|
{confirming && option && (
|
||||||
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
|
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
|
||||||
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Dialog>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
import { FaRotate, FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Badge, Button, Card } from '../ui'
|
import { Badge, Button, Card } from '../ui'
|
||||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||||
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi'
|
import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi'
|
||||||
import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings'
|
import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings'
|
||||||
import { safeUrl } from '../../lib/financeView'
|
import { safeUrl } from '../../lib/financeView'
|
||||||
@@ -67,14 +67,11 @@ export default function BankAccessCard() {
|
|||||||
return (
|
return (
|
||||||
<Card title="Bankzugang">
|
<Card title="Bankzugang">
|
||||||
{res.loading ? (
|
{res.loading ? (
|
||||||
<div className="empty" role="status">
|
<div className="loading" role="status">Bankzugang wird geladen …</div>
|
||||||
<FaSpinner className="spinner" aria-hidden="true" />
|
|
||||||
<p className="muted">Bankzugang wird geladen …</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
<div className="fin-load-error">
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
|
||||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
<Button onClick={res.reload}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -85,36 +82,28 @@ export default function BankAccessCard() {
|
|||||||
return (
|
return (
|
||||||
<Card title="Bankzugang" actions={<Badge tone={model.tone}>{model.statusLabel}</Badge>}>
|
<Card title="Bankzugang" actions={<Badge tone={model.tone}>{model.statusLabel}</Badge>}>
|
||||||
{res.error && (
|
{res.error && (
|
||||||
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">
|
<p {...noticeProps('warn', 'fin-notice')}>Der Stand konnte nicht neu geladen werden: {res.error}</p>
|
||||||
Der Stand konnte nicht neu geladen werden: {res.error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{model.rows.length > 0 && (
|
{model.rows.length > 0 && (
|
||||||
<dl className="fin-bank-facts">
|
<dl className="kv fin-kv">
|
||||||
{model.rows.map((row) => (
|
{model.rows.map((row) => (
|
||||||
<div key={row.key}>
|
<Fragment key={row.key}>
|
||||||
<dt>{row.label}</dt>
|
<dt>{row.label}</dt>
|
||||||
<dd>{row.text}</dd>
|
<dd className="num">{row.text}</dd>
|
||||||
</div>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
{model.error && (
|
{model.error && <p {...noticeProps('warn', 'fin-notice')}>Fehler: {model.error}</p>}
|
||||||
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">Fehler: {model.error}</p>
|
{model.hint && <p {...noticeProps('info', 'fin-notice')}>{model.hint}</p>}
|
||||||
)}
|
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</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>
|
|
||||||
)}
|
|
||||||
<div className="fin-form-actions">
|
<div className="fin-form-actions">
|
||||||
{model.canRefresh && (
|
{model.canRefresh && (
|
||||||
<Button variant="primary" onClick={refresh} disabled={busy !== null}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<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}
|
{busy === 'connect' ? 'Leite weiter …' : model.connectLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Card, EmptyState } from '../ui'
|
import { Button, EmptyState } from '../ui'
|
||||||
import AddressWarning from './AddressWarning'
|
import AddressWarning from './AddressWarning'
|
||||||
import InvoiceTable from './InvoiceTable'
|
import InvoiceTable from './InvoiceTable'
|
||||||
import { customerInvoicesGate } from '../../lib/financeView'
|
import { customerInvoicesGate } from '../../lib/financeView'
|
||||||
@@ -13,13 +13,11 @@ export default function CustomerInvoices({ customer, onShowOverview }) {
|
|||||||
const gate = customerInvoicesGate(customer)
|
const gate = customerInvoicesGate(customer)
|
||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={gate.title}
|
title={gate.title}
|
||||||
hint={gate.hint}
|
hint={gate.hint}
|
||||||
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { 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 } from '../ui'
|
||||||
import FormField from './FormField'
|
import FormField from './FormField'
|
||||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||||
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
|
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
|
||||||
import {
|
import {
|
||||||
FIXED_COST_DELETE_CONFIRM,
|
FIXED_COST_DELETE_CONFIRM,
|
||||||
@@ -146,45 +146,44 @@ export default function FixedCostsCard() {
|
|||||||
let body
|
let body
|
||||||
if (!res.data) {
|
if (!res.data) {
|
||||||
body = res.loading ? (
|
body = res.loading ? (
|
||||||
<div className="empty" role="status">
|
<div className="loading" role="status">Fixkosten werden geladen …</div>
|
||||||
<FaSpinner className="spinner" aria-hidden="true" />
|
|
||||||
<p className="muted">Fixkosten werden geladen …</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
<div className="fin-load-error">
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
|
||||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
<Button onClick={res.reload}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
{res.error && (
|
{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}{' '}
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{editing && (
|
{editing && (
|
||||||
<form className="fin-fixed-form" onSubmit={submit} noValidate aria-labelledby="fin-fix-form-title">
|
<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'}>
|
<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}>
|
<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>
|
||||||
<FormField id={fieldId('amount')} label="Betrag (€)" error={errors.amount}>
|
<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>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
|
<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) => (
|
{INTERVAL_OPTIONS.map((o) => (
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id={fieldId('nextDate')} label="Nächster Termin" error={errors.nextDate}>
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<FormField
|
<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.`}
|
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}
|
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>
|
</FormField>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="fin-form-actions">
|
<div className="fin-form-actions">
|
||||||
<Button type="submit" variant="primary" disabled={busy !== null}>
|
<Button type="submit" variant="primary" disabled={busy !== null}>
|
||||||
{busy === 'save' ? 'Speichere …' : 'Speichern'}
|
{busy === 'save' ? 'Speichere …' : 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
|
<Button type="button" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{message && (
|
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
|
||||||
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
|
||||||
{message.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<EmptyState title="Noch keine Fixkosten." hint="Lege regelmäßige Ausgaben an, damit Vorschau und „Verfügbar“ sie berücksichtigen." />
|
<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-amount fin-fixed-amount">{row.amountText}</div>
|
||||||
<div className="fin-fixed-meta muted">{row.metaText}</div>
|
<div className="fin-fixed-meta muted">{row.metaText}</div>
|
||||||
<div className="fin-actions fin-fixed-actions">
|
<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
|
Bearbeiten
|
||||||
</Button>
|
</button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
|
<button type="button" className="link" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
|
||||||
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
|
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
|
||||||
</Button>
|
</button>
|
||||||
<Button size="sm" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
|
<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'}
|
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,8 +240,8 @@ export default function FixedCostsCard() {
|
|||||||
title="Fixkosten"
|
title="Fixkosten"
|
||||||
actions={
|
actions={
|
||||||
res.data && (
|
res.data && (
|
||||||
<Button size="sm" variant="primary" onClick={openNew} disabled={busy !== null || editing?.id === null}>
|
<Button onClick={openNew} disabled={busy !== null || editing?.id === null}>
|
||||||
<FaPlus aria-hidden="true" /> Neue Position
|
Neue Position
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { FaTriangleExclamation } from 'react-icons/fa6'
|
|
||||||
import { Badge, Card, EmptyState } from '../ui'
|
import { Badge, Card, EmptyState } from '../ui'
|
||||||
import { forecastNotes, forecastRows } from '../../lib/financeView'
|
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
|
* 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).
|
* 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)
|
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Wrapper .fin-body bis
|
||||||
* wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
|
* 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 }) {
|
export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
|
||||||
const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
|
const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
|
||||||
@@ -15,12 +15,13 @@ export default function ForecastTable({ vorschau, kontostandCents, stripeDown =
|
|||||||
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
|
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Vorschau 30 Tage" className="fin-forecast-card" bodyStyle={{ padding: 0 }}>
|
<Card title="Vorschau 30 Tage">
|
||||||
|
<div className="fin-body">
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
|
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
|
||||||
) : (
|
) : (
|
||||||
<div className="fin-table-wrap">
|
<div className="fin-table-wrap">
|
||||||
<table className={`ui-table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
|
<table className={`table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
|
||||||
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
|
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
|
||||||
<thead role="rowgroup">
|
<thead role="rowgroup">
|
||||||
<tr role="row">
|
<tr role="row">
|
||||||
@@ -44,7 +45,12 @@ export default function ForecastTable({ vorschau, kontostandCents, stripeDown =
|
|||||||
<td role="cell" className="fin-forecast-kind">
|
<td role="cell" className="fin-forecast-kind">
|
||||||
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
|
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td role="cell" className="fin-amount fin-forecast-amount">{row.amountText}</td>
|
<td
|
||||||
|
role="cell"
|
||||||
|
className={`fin-amount fin-forecast-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}
|
||||||
|
>
|
||||||
|
{row.amountText}
|
||||||
|
</td>
|
||||||
{hasBalance && (
|
{hasBalance && (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
@@ -72,13 +78,14 @@ export default function ForecastTable({ vorschau, kontostandCents, stripeDown =
|
|||||||
{(notes.length > 0 || assumption) && (
|
{(notes.length > 0 || assumption) && (
|
||||||
<div className="fin-forecast-foot">
|
<div className="fin-forecast-foot">
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
<p key={note.key} className="fin-hint fin-hint-warn" role="status">
|
<p key={note.key} className="msg warn" role="status">
|
||||||
<FaTriangleExclamation aria-hidden="true" /> {note.text}
|
{note.text}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{assumption && <p className="fin-hint">{assumption}</p>}
|
{assumption && <p className="msg muted">{assumption}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { cloneElement } from 'react'
|
import { cloneElement } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. `children` ist genau ein Eingabeelement; es bekommt
|
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung (Klassen des Bausteins `Field`: `.field`, `.hint`, `.msg.err`).
|
||||||
* `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler). Die Fehlermeldung wird vorgelesen (role="alert").
|
* `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 }) {
|
export default function FormField({ id, label, hint, error, children }) {
|
||||||
const hintId = hint ? `${id}-hint` : undefined
|
const hintId = hint ? `${id}-hint` : undefined
|
||||||
@@ -13,12 +14,12 @@ export default function FormField({ id, label, hint, error, children }) {
|
|||||||
<label htmlFor={id}>{label}</label>
|
<label htmlFor={id}>{label}</label>
|
||||||
{cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })}
|
{cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })}
|
||||||
{hint && (
|
{hint && (
|
||||||
<div id={hintId} className="faint fin-form-hint">
|
<div id={hintId} className="hint">
|
||||||
{hint}
|
{hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div id={errorId} className="fin-field-error" role="alert">
|
<div id={errorId} className="msg err" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
|
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
|
||||||
import { AutocompleteInput, Button } from '../ui'
|
|
||||||
import AddressWarning from './AddressWarning'
|
import AddressWarning from './AddressWarning'
|
||||||
import { useCustomers } from '../../hooks/useCustomers'
|
import { useCustomers } from '../../hooks/useCustomers'
|
||||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
|
||||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { createInvoice } from '../../lib/financeApi'
|
import { createInvoice } from '../../lib/financeApi'
|
||||||
import { canInvoice, euro } from '../../lib/financeFormat'
|
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 [busy, setBusy] = useState(null) // 'draft' | 'send'
|
||||||
const lockRef = useRef(false)
|
const lockRef = useRef(false)
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
const dialogRef = useRef(null)
|
|
||||||
const errorRef = 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
|
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
|
||||||
useScrollIntoView(errorRef, error)
|
useScrollIntoView(errorRef, error)
|
||||||
|
|
||||||
@@ -79,14 +75,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
// × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
|
||||||
const onKey = (e) => {
|
// Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
|
||||||
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
|
const close = () => {
|
||||||
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
|
if (lockRef.current) return
|
||||||
|
onClose()
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
|
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
|
||||||
const resolved = fixedCustomer
|
const resolved = fixedCustomer
|
||||||
@@ -144,40 +138,43 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
|
|
||||||
const disabled = busy !== null
|
const disabled = busy !== null
|
||||||
|
|
||||||
return (
|
const footer = (
|
||||||
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
|
<div className="fin-foot">
|
||||||
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
|
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
|
||||||
<FaXmark aria-hidden="true" />
|
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
||||||
</button>
|
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
||||||
<div
|
</Button>
|
||||||
className="overlay-content fin-dialog"
|
<Button
|
||||||
role="dialog"
|
type="button"
|
||||||
aria-modal="true"
|
variant="primary"
|
||||||
aria-labelledby="fin-invoice-title"
|
onClick={() => submit(true)}
|
||||||
tabIndex={-1}
|
disabled={disabled || !gate.sendAllowed}
|
||||||
ref={dialogRef}
|
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||||
>
|
>
|
||||||
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
|
{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 && (
|
{error && (
|
||||||
<div className="fin-dialog-error" role="alert" ref={errorRef}>
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
|
<Field label="Kunde" htmlFor="fin-invoice-customer">
|
||||||
<form onSubmit={(e) => e.preventDefault()}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
|
|
||||||
{fixedCustomer ? (
|
{fixedCustomer ? (
|
||||||
<p className="fin-fixed-customer">
|
<p className="fin-fixed-customer">
|
||||||
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
|
<strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
|
||||||
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
|
{fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
) : source?.loading ? (
|
) : source?.loading ? (
|
||||||
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
|
<p className="msg muted" role="status">Kunden werden geladen …</p>
|
||||||
) : source?.error ? (
|
) : source?.error ? (
|
||||||
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
<p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
||||||
) : (
|
) : (
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
id="fin-invoice-customer"
|
id="fin-invoice-customer"
|
||||||
@@ -191,15 +188,15 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!customerBlocked.ok && (
|
{!customerBlocked.ok && (
|
||||||
<p className="fin-dialog-warn" role="alert">
|
<p className="msg warn" role="alert">
|
||||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!fixedCustomer && resolved.state === 'unknown' && (
|
{!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>
|
<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 && (
|
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
||||||
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
|
<p className="msg muted">Die Rechnung geht an {customer.email}.</p>
|
||||||
)}
|
)}
|
||||||
{gate.warn && (
|
{gate.warn && (
|
||||||
<AddressWarning
|
<AddressWarning
|
||||||
@@ -216,10 +213,10 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<fieldset className="fin-items">
|
<fieldset className="fin-items">
|
||||||
<legend className="form-label">Positionen</legend>
|
<legend>Positionen</legend>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const line = lineTotalCents(item)
|
const line = lineTotalCents(item)
|
||||||
const n = index + 1
|
const n = index + 1
|
||||||
@@ -229,7 +226,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
||||||
<input
|
<input
|
||||||
id={`fin-item-desc-${item.uid}`}
|
id={`fin-item-desc-${item.uid}`}
|
||||||
className="form-control"
|
className="input"
|
||||||
maxLength={INVOICE_LIMITS.maxDescription}
|
maxLength={INVOICE_LIMITS.maxDescription}
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
||||||
@@ -241,7 +238,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
||||||
<input
|
<input
|
||||||
id={`fin-item-qty-${item.uid}`}
|
id={`fin-item-qty-${item.uid}`}
|
||||||
className="form-control fin-amount"
|
className="input fin-amount"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
||||||
@@ -252,7 +249,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
||||||
<input
|
<input
|
||||||
id={`fin-item-price-${item.uid}`}
|
id={`fin-item-price-${item.uid}`}
|
||||||
className="form-control fin-amount"
|
className="input fin-amount"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={item.price}
|
value={item.price}
|
||||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
||||||
@@ -267,13 +264,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
<div className="fin-item-field fin-item-remove">
|
<div className="fin-item-field fin-item-remove">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="fin-icon-btn"
|
className="link"
|
||||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
||||||
disabled={items.length <= 1}
|
disabled={items.length <= 1}
|
||||||
aria-label={`Position ${n} entfernen`}
|
aria-label={`Position ${n} entfernen`}
|
||||||
title="Position entfernen"
|
|
||||||
>
|
>
|
||||||
<FaTrashCan aria-hidden="true" />
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,27 +278,24 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
<div className="fin-items-foot">
|
<div className="fin-items-foot">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
||||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
||||||
>
|
>
|
||||||
<FaPlus aria-hidden="true" /> Position
|
+ Position
|
||||||
</Button>
|
</Button>
|
||||||
<div className="fin-total" aria-live="polite">
|
<div className="fin-total" aria-live="polite">
|
||||||
<span>Summe</span>
|
<span>Summe</span>
|
||||||
<span className="fin-amount fin-total-value">{euro(total)}</span>
|
<span className="fin-amount">{euro(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
<p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="fin-dialog-row">
|
<div className="fin-dialog-row">
|
||||||
<div className="form-group fin-days">
|
<Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
|
||||||
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
|
|
||||||
<input
|
<input
|
||||||
id="fin-invoice-days"
|
id="fin-invoice-days"
|
||||||
className="form-control fin-amount"
|
className="input fin-amount"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={days}
|
value={days}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -310,37 +303,18 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
|
|||||||
if (error) setError('')
|
if (error) setError('')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="form-group fin-memo">
|
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
|
||||||
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="fin-invoice-memo"
|
id="fin-invoice-memo"
|
||||||
className="form-control"
|
className="textarea"
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={INVOICE_LIMITS.maxMemo}
|
maxLength={INVOICE_LIMITS.maxMemo}
|
||||||
value={memo}
|
value={memo}
|
||||||
onChange={(e) => setMemo(e.target.value)}
|
onChange={(e) => setMemo(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dialog>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaPlus, FaSpinner } from 'react-icons/fa6'
|
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
|
||||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
|
||||||
import InvoiceCreateModal from './InvoiceCreateModal'
|
import InvoiceCreateModal from './InvoiceCreateModal'
|
||||||
import Pager from './Pager'
|
import Pager from './Pager'
|
||||||
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
||||||
import { pageOf } from '../../lib/financeFormat'
|
import { pageOf } from '../../lib/financeFormat'
|
||||||
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
import {
|
import {
|
||||||
ADDRESS_INCOMPLETE_TEXT,
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
DEFAULT_INVOICE_FILTER,
|
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.
|
* 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
|
* 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
|
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Wrapper .fin-body bis 860 px)
|
||||||
* Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
|
* 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
|
* 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.
|
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
|
||||||
*
|
*
|
||||||
@@ -147,37 +147,35 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
changed()
|
changed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
|
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
|
||||||
<Card
|
<Card
|
||||||
title="Rechnungen"
|
title="Rechnungen"
|
||||||
className="fin-invoices-card"
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
actions={
|
actions={
|
||||||
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||||
<FaPlus aria-hidden="true" /> Rechnung schreiben
|
Rechnung schreiben
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="fin-body">
|
||||||
{ages.length > 0 && (
|
{ages.length > 0 && (
|
||||||
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
|
<ul className="stats fin-ages" aria-label="Offene Rechnungen nach Alter">
|
||||||
{ages.map((cell) => (
|
{ages.map((cell) => (
|
||||||
<li key={cell.key} className="fin-age">
|
<li key={cell.key}>
|
||||||
<span className="fin-age-label">{cell.label}</span>
|
<Stat label={cell.label} value={cell.sumText} sub={`${cell.count} ${cell.count === 1 ? 'Rechnung' : 'Rechnungen'}`} />
|
||||||
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
|
|
||||||
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="fin-toolbar">
|
<div className="toolbar">
|
||||||
<div className="seg" role="group" aria-label="Rechnungen filtern">
|
<div className="seg" role="group" aria-label="Rechnungen filtern">
|
||||||
{INVOICE_FILTERS.map((f) => (
|
{INVOICE_FILTERS.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.key}
|
key={f.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={status === f.key ? 'active' : ''}
|
className={status === f.key ? 'on' : ''}
|
||||||
aria-pressed={status === f.key}
|
aria-pressed={status === f.key}
|
||||||
onClick={() => pickFilter(f.key)}
|
onClick={() => pickFilter(f.key)}
|
||||||
>
|
>
|
||||||
@@ -187,27 +185,20 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
|
||||||
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
|
||||||
{message.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty" role="status">
|
<div className="loading" role="status">Rechnungen werden geladen …</div>
|
||||||
<FaSpinner className="spinner" aria-hidden="true" />
|
|
||||||
<p className="muted">Rechnungen werden geladen …</p>
|
|
||||||
</div>
|
|
||||||
) : loadError ? (
|
) : loadError ? (
|
||||||
<div className="fin-load-error">
|
<div className="fin-load-error">
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
|
<p className="msg warn" role="alert">{loadError}</p>
|
||||||
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
|
<Button onClick={reload}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
|
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
|
||||||
) : (
|
) : (
|
||||||
<div className="fin-table-wrap">
|
<div className="fin-table-wrap">
|
||||||
<table className="ui-table fin-invoices" role="table">
|
<table className="table fin-invoices" role="table">
|
||||||
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
|
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
|
||||||
<thead role="rowgroup">
|
<thead role="rowgroup">
|
||||||
<tr role="row">
|
<tr role="row">
|
||||||
@@ -241,34 +232,52 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
</td>
|
</td>
|
||||||
<td role="cell" className="fin-inv-actions">
|
<td role="cell" className="fin-inv-actions">
|
||||||
<div className="fin-actions">
|
<div className="fin-actions">
|
||||||
{row.actions.map((action) =>
|
{row.actions.map((action) => {
|
||||||
action.url ? (
|
const label = `${action.label}: ${row.numberText}, ${row.customerText}`
|
||||||
<Button
|
if (action.url) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
key={action.key}
|
key={action.key}
|
||||||
as="a"
|
className="link"
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
href={action.url}
|
href={action.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
|
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</a>
|
||||||
) : (
|
)
|
||||||
|
}
|
||||||
|
const running = busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label
|
||||||
|
if (action.key === 'delete' || action.key === 'void') {
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={action.key}
|
key={action.key}
|
||||||
size="sm"
|
type="button"
|
||||||
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
|
variant="danger"
|
||||||
onClick={() => run(row, action)}
|
onClick={() => run(row, action)}
|
||||||
disabled={busy !== null || (action.key === 'send' && sendBlocked(byId[row.id]))}
|
disabled={busy !== null}
|
||||||
title={action.key === 'send' && sendBlocked(byId[row.id]) ? ADDRESS_INCOMPLETE_TEXT : undefined}
|
aria-label={label}
|
||||||
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
|
|
||||||
>
|
>
|
||||||
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
|
{running}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -278,6 +287,7 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
|
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<InvoiceCreateModal
|
<InvoiceCreateModal
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { FaChevronDown, FaChevronUp } from 'react-icons/fa6'
|
import { Stat } from '../ui'
|
||||||
import { Kpi } from '../ui'
|
|
||||||
import { euroWhole } from '../../lib/financeFormat'
|
import { euroWhole } from '../../lib/financeFormat'
|
||||||
import { breakdownRows, lowPointLines } from '../../lib/financeView'
|
import { breakdownRows, lowPointLines } from '../../lib/financeView'
|
||||||
|
import { isNegText } from '../../lib/financeUi'
|
||||||
|
|
||||||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||||
|
|
||||||
@@ -15,20 +15,21 @@ export default function LiquidityHead({ kopf }) {
|
|||||||
const hasBalance = isNum(k.kontostandCents)
|
const hasBalance = isNum(k.kontostandCents)
|
||||||
const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
|
const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
|
||||||
const lows = lowPointLines(k)
|
const lows = lowPointLines(k)
|
||||||
const anyLowNegative = lows.some((l) => l && l.negative)
|
const balanceText = euroWhole(k.kontostandCents)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kpi-grid fin-head">
|
<div className="stats fin-head">
|
||||||
<Kpi
|
<Stat
|
||||||
label="Kontostand"
|
label="Kontostand"
|
||||||
value={euroWhole(k.kontostandCents)}
|
value={balanceText}
|
||||||
|
tone={isNegText(balanceText) ? 'neg' : ''}
|
||||||
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
|
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Kpi
|
<Stat
|
||||||
label="Verfügbar nach Rücklagen"
|
label="Verfügbar nach Rücklagen"
|
||||||
value={euroWhole(k.verfuegbarCents)}
|
value={euroWhole(k.verfuegbarCents)}
|
||||||
accent={shortfall ? 'var(--danger)' : 'var(--accent)'}
|
tone={shortfall ? 'neg' : ''}
|
||||||
sub={
|
sub={
|
||||||
<>
|
<>
|
||||||
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
|
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
|
||||||
@@ -36,12 +37,12 @@ export default function LiquidityHead({ kopf }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="fin-link"
|
className="link"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={listId}
|
aria-controls={listId}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
Aufschlüsselung {open ? <FaChevronUp aria-hidden="true" /> : <FaChevronDown aria-hidden="true" />}
|
Aufschlüsselung <span aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<ul id={listId} className="fin-breakdown">
|
<ul id={listId} className="fin-breakdown">
|
||||||
@@ -60,9 +61,8 @@ export default function LiquidityHead({ kopf }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Kpi
|
<Stat
|
||||||
label="Tiefster Stand in 30 Tagen"
|
label="Tiefster Stand in 30 Tagen"
|
||||||
accent={anyLowNegative ? 'var(--danger)' : 'var(--info)'}
|
|
||||||
value={
|
value={
|
||||||
lows.some(Boolean) ? (
|
lows.some(Boolean) ? (
|
||||||
<div className="fin-lows">
|
<div className="fin-lows">
|
||||||
@@ -71,7 +71,7 @@ export default function LiquidityHead({ kopf }) {
|
|||||||
low && (
|
low && (
|
||||||
<div key={low.key} className="fin-low">
|
<div key={low.key} className="fin-low">
|
||||||
<span className="fin-low-label">{low.label}:</span>
|
<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">
|
<span className="fin-low-when">
|
||||||
am {low.dateText}
|
am {low.dateText}
|
||||||
{low.negative && <span className="fin-flag"> · im Minus</span>}
|
{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 }) {
|
export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
|
||||||
if (pages <= 1) return null
|
if (pages <= 1) return null
|
||||||
return (
|
return (
|
||||||
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
|
<nav className="table-foot fin-pager" aria-label={`Seiten der ${noun}`}>
|
||||||
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
|
<Button type="button" variant="quiet" onClick={() => onPage(page - 1)} disabled={page <= 1}>
|
||||||
Zurück
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
<span className="fin-pager-info" aria-live="polite">
|
<span className="fin-pager-info" aria-live="polite">
|
||||||
Seite {page} von {pages}
|
Seite {page} von {pages}
|
||||||
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
|
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
|
||||||
</span>
|
</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
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FaFloppyDisk, FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Button, Card } from '../ui'
|
import { Button, Card } from '../ui'
|
||||||
import FormField from './FormField'
|
import FormField from './FormField'
|
||||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||||
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
import { getSettings, patchSettings } from '../../lib/financeApi'
|
import { getSettings, patchSettings } from '../../lib/financeApi'
|
||||||
import {
|
import {
|
||||||
BANK_DETAILS_EXAMPLE,
|
BANK_DETAILS_EXAMPLE,
|
||||||
@@ -29,14 +29,11 @@ export default function SettingsCard() {
|
|||||||
return (
|
return (
|
||||||
<Card title="Einstellungen">
|
<Card title="Einstellungen">
|
||||||
{res.loading ? (
|
{res.loading ? (
|
||||||
<div className="empty" role="status">
|
<div className="loading" role="status">Einstellungen werden geladen …</div>
|
||||||
<FaSpinner className="spinner" aria-hidden="true" />
|
|
||||||
<p className="muted">Einstellungen werden geladen …</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
<div className="fin-load-error">
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
|
||||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
<Button onClick={res.reload}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -103,14 +100,14 @@ function SettingsForm({ initial }) {
|
|||||||
<form onSubmit={submit} noValidate>
|
<form onSubmit={submit} noValidate>
|
||||||
{/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */}
|
{/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */}
|
||||||
<fieldset className="fin-fieldset" disabled={busy}>
|
<fieldset className="fin-fieldset" disabled={busy}>
|
||||||
<div className="fin-form-grid">
|
<div className="field-row">
|
||||||
<FormField
|
<FormField
|
||||||
id={fieldId('minBuffer')}
|
id={fieldId('minBuffer')}
|
||||||
label="Mindestpuffer (€)"
|
label="Mindestpuffer (€)"
|
||||||
hint="Dieser Betrag bleibt bei „Verfügbar“ immer auf dem Konto. 0 ist möglich."
|
hint="Dieser Betrag bleibt bei „Verfügbar“ immer auf dem Konto. 0 ist möglich."
|
||||||
error={errors.minBuffer}
|
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>
|
||||||
<FormField
|
<FormField
|
||||||
id={fieldId('taxReservePercent')}
|
id={fieldId('taxReservePercent')}
|
||||||
@@ -118,15 +115,17 @@ function SettingsForm({ initial }) {
|
|||||||
hint={`Anteil des Gewinns, der als Rücklage gilt (0 bis ${SETTINGS_LIMITS.maxPercent}).`}
|
hint={`Anteil des Gewinns, der als Rücklage gilt (0 bis ${SETTINGS_LIMITS.maxPercent}).`}
|
||||||
error={errors.taxReservePercent}
|
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>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
<FormField
|
<FormField
|
||||||
id={fieldId('previousYearRevenue')}
|
id={fieldId('previousYearRevenue')}
|
||||||
label="Vorjahresumsatz (€)"
|
label="Vorjahresumsatz (€)"
|
||||||
hint="Für die Kleinunternehmer-Grenze. Leer lassen, wenn unbekannt."
|
hint="Für die Kleinunternehmer-Grenze. Leer lassen, wenn unbekannt."
|
||||||
error={errors.previousYearRevenue}
|
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>
|
||||||
<FormField
|
<FormField
|
||||||
id={fieldId('estPrepayment')}
|
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."
|
hint="Betrag je Termin (10.03., 10.06., 10.09., 10.12.). 0 = keine Vorauszahlung."
|
||||||
error={errors.estPrepayment}
|
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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,21 +143,17 @@ function SettingsForm({ initial }) {
|
|||||||
hint={`Steht im Fuß jeder Rechnung, z. B. „${BANK_DETAILS_EXAMPLE}“.`}
|
hint={`Steht im Fuß jeder Rechnung, z. B. „${BANK_DETAILS_EXAMPLE}“.`}
|
||||||
error={errors.bankDetailsText}
|
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>
|
</FormField>
|
||||||
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
|
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
|
||||||
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{notice && (
|
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
|
||||||
<p className={`fin-hint fin-form-notice${notice.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={notice.tone === 'warn' ? 'alert' : 'status'}>
|
|
||||||
{notice.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="fin-form-actions">
|
<div className="fin-form-actions">
|
||||||
<Button type="submit" variant="primary" disabled={busy}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FaRotate, FaTriangleExclamation } from 'react-icons/fa6'
|
|
||||||
import { Button } from '../ui'
|
import { Button } from '../ui'
|
||||||
import { statusItems } from '../../lib/financeView'
|
import { statusItems } from '../../lib/financeView'
|
||||||
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block 0: Stand von Bank und Stripe in einer Zeile und der Knopf „Aktualisieren“.
|
* 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">
|
<div className="fin-status-items">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
|
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
|
||||||
{!item.ok && <FaTriangleExclamation aria-hidden="true" />}
|
|
||||||
{item.text}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="ghost" onClick={handleRefresh} disabled={refreshing}>
|
<Button onClick={handleRefresh} disabled={refreshing}>
|
||||||
<FaRotate className={refreshing ? 'fin-spin' : undefined} aria-hidden="true" />{' '}
|
|
||||||
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
|
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{hint && (
|
{hint && <p {...noticeProps(hint.limited ? 'info' : 'warn', 'fin-notice')}>{hint.text}</p>}
|
||||||
<p className={`fin-hint${hint.limited ? '' : ' fin-hint-warn'}`} role={hint.limited ? 'status' : 'alert'}>
|
|
||||||
{hint.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Card } from '../ui'
|
import { Card } from '../ui'
|
||||||
import { taxView } from '../../lib/financeView'
|
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,
|
* Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
|
||||||
@@ -10,12 +11,15 @@ import { taxView } from '../../lib/financeView'
|
|||||||
export default function TaxCard({ steuer }) {
|
export default function TaxCard({ steuer }) {
|
||||||
const v = taxView(steuer)
|
const v = taxView(steuer)
|
||||||
return (
|
return (
|
||||||
<Card title="Steuer" className="fin-tax-card">
|
<Card title="Steuer">
|
||||||
|
<div className="fin-body">
|
||||||
<div className="fin-facts">
|
<div className="fin-facts">
|
||||||
<div className="fin-fact">
|
<div className="fin-fact">
|
||||||
<div className="fin-fact-row">
|
<div className="fin-fact-row">
|
||||||
<span className="fin-fact-label">Steuer-Rücklage</span>
|
<span className="fin-fact-label">Steuer-Rücklage</span>
|
||||||
<span className="fin-amount fin-fact-big">{v.reserve.amountText}</span>
|
<span className={`fin-amount fin-fact-big${isNegText(v.reserve.amountText) ? ' fin-neg' : ''}`}>
|
||||||
|
{v.reserve.amountText}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
|
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,13 +44,16 @@ export default function TaxCard({ steuer }) {
|
|||||||
<div className="fin-fact">
|
<div className="fin-fact">
|
||||||
<div className="fin-fact-row">
|
<div className="fin-fact-row">
|
||||||
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
|
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
|
||||||
<span className="fin-amount fin-fact-strong">{v.prepayment.amountText}</span>
|
<span className={`fin-amount fin-fact-strong${isNegText(v.prepayment.amountText) ? ' fin-neg' : ''}`}>
|
||||||
|
{v.prepayment.amountText}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
|
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="fin-hint fin-tax-note">{v.noteText}</p>
|
<p className="msg muted fin-tax-note">{v.noteText}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
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'
|
import { TODO_LIMIT, todoButtonLabel, todoKey, urgencyOf, visibleTodos } from '../../lib/financeView'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,12 +35,7 @@ export default function TodoList({ aufgaben, onAction }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card title="Zu erledigen" actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}>
|
||||||
title="Zu erledigen"
|
|
||||||
actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}
|
|
||||||
className="fin-todo-card"
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
{total === 0 ? (
|
{total === 0 ? (
|
||||||
<EmptyState title="Nichts offen" />
|
<EmptyState title="Nichts offen" />
|
||||||
) : (
|
) : (
|
||||||
@@ -51,31 +46,31 @@ export default function TodoList({ aufgaben, onAction }) {
|
|||||||
const urgency = urgencyOf(todo)
|
const urgency = urgencyOf(todo)
|
||||||
const label = todoButtonLabel(todo)
|
const label = todoButtonLabel(todo)
|
||||||
return (
|
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>
|
<Badge tone={urgency.tone}>{urgency.label}</Badge>
|
||||||
<div className="fin-todo-main">
|
<div>
|
||||||
<div className="fin-todo-title">{todo.titel}</div>
|
<div className="fin-todo-title">{todo.titel}</div>
|
||||||
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
|
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
|
||||||
{failure?.key === key && (
|
{failure?.key === key && (
|
||||||
<div className="fin-hint fin-hint-warn" role="alert">
|
<div className="msg warn" role="alert">
|
||||||
{failure.message}
|
{failure.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{label && (
|
{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}
|
{running === key ? 'Moment …' : label}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{total > TODO_LIMIT && (
|
{total > TODO_LIMIT && (
|
||||||
<div className="fin-todos-more">
|
<div className="table-foot">
|
||||||
<Button size="sm" variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
<button type="button" className="link" onClick={() => setExpanded((v) => !v)}>
|
||||||
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
|
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
|
|
||||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||||
import Pager from './Pager'
|
import Pager from './Pager'
|
||||||
import { pageOf } from '../../lib/financeFormat'
|
import { pageOf } from '../../lib/financeFormat'
|
||||||
|
import { isNegText, noticeProps } from '../../lib/financeUi'
|
||||||
import { transactionRows } from '../../lib/financeView'
|
import { transactionRows } from '../../lib/financeView'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
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
|
* 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
|
* 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.
|
* 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 /
|
* onAssign(buchung) öffnet den Dialog. Schmal (Wrapper .fin-body bis 640 px) wird jede Zeile ein Raster: Datum und
|
||||||
* Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
|
* 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 }) {
|
export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@@ -24,16 +24,13 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
|||||||
let body
|
let body
|
||||||
if (!loaded && loading) {
|
if (!loaded && loading) {
|
||||||
body = (
|
body = (
|
||||||
<div className="empty" role="status">
|
<div className="loading" role="status">Buchungen werden geladen …</div>
|
||||||
<FaSpinner className="spinner" aria-hidden="true" />
|
|
||||||
<p className="muted">Buchungen werden geladen …</p>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
} else if (!loaded) {
|
} else if (!loaded) {
|
||||||
body = (
|
body = (
|
||||||
<div className="fin-load-error">
|
<div className="fin-load-error">
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
|
<p className="msg warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
|
||||||
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
|
<Button onClick={onReload}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (rows.length === 0) {
|
} else if (rows.length === 0) {
|
||||||
@@ -41,7 +38,7 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
|||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<div className="fin-table-wrap">
|
<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>
|
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
|
||||||
<thead role="rowgroup">
|
<thead role="rowgroup">
|
||||||
<tr role="row">
|
<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>
|
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td role="cell" className="fin-tx-assign">
|
||||||
<div className="fin-tx-badges">
|
<div className="fin-tx-badges">
|
||||||
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
|
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
|
||||||
@@ -73,14 +75,14 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
|||||||
</td>
|
</td>
|
||||||
<td role="cell" className="fin-tx-action">
|
<td role="cell" className="fin-tx-action">
|
||||||
{row.action && (
|
{row.action && (
|
||||||
<Button
|
<button
|
||||||
size="sm"
|
type="button"
|
||||||
variant={row.action === 'assign' ? 'default' : 'ghost'}
|
className="link"
|
||||||
onClick={() => onAssign(row.tx)}
|
onClick={() => onAssign(row.tx)}
|
||||||
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
|
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
|
||||||
>
|
>
|
||||||
{row.actionLabel}
|
{row.actionLabel}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -93,20 +95,22 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
|
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
|
||||||
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
|
<Card title="Buchungen">
|
||||||
|
<div className="fin-body">
|
||||||
{loaded && error && (
|
{loaded && error && (
|
||||||
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
|
<p {...noticeProps('warn', 'fin-notice')}>
|
||||||
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
|
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
|
||||||
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
|
<button type="button" className="link" onClick={onReload} disabled={loading}>Erneut versuchen</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{stripeFehler && (
|
{stripeFehler && (
|
||||||
<p className="fin-hint fin-hint-warn fin-notice" role="status">
|
<p className="msg warn fin-notice" role="status">
|
||||||
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
|
{stripeFehler}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{body}
|
{body}
|
||||||
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
|
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { Button } from '../ui'
|
import { Button, Dialog, Field } from '../ui'
|
||||||
|
|
||||||
const FIELDS = ['provider', 'purpose', 'cost', 'costInterval', 'startDate', 'termMonths', 'renewalMonths', 'noticeValue', 'noticeUnit', 'note']
|
const FIELDS = ['provider', 'purpose', 'cost', 'costInterval', 'startDate', 'termMonths', 'renewalMonths', 'noticeValue', 'noticeUnit', 'note']
|
||||||
const NUMBERS = ['termMonths', 'renewalMonths', 'noticeValue']
|
const NUMBERS = ['termMonths', 'renewalMonths', 'noticeValue']
|
||||||
@@ -31,6 +31,8 @@ export default function ContractForm({ contract, onSave, onClose }) {
|
|||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
@@ -47,60 +49,59 @@ export default function ContractForm({ contract, onSave, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = (key, label, props = {}) => (
|
const input = (key, label, props = {}) => (
|
||||||
<div className="form-group">
|
<Field label={label} htmlFor={`contract-${key}`}>
|
||||||
<label className="form-label">{label}</label>
|
<input className="input" id={`contract-${key}`} name={key} value={form[key] || ''} onChange={set(key)} {...props} />
|
||||||
<input className="form-control" name={key} value={form[key] || ''} onChange={set(key)} {...props} />
|
</Field>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
|
as="form"
|
||||||
<div className="overlay-content">
|
wide
|
||||||
<h2 className="mb-2">{contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}</h2>
|
title={contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
||||||
{error && <div className="legal-error">{error}</div>}
|
busy={saving}
|
||||||
<form onSubmit={submit}>
|
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('provider', 'Anbieter', { required: true })}
|
||||||
{input('purpose', 'Wofür')}
|
{input('purpose', 'Wofür')}
|
||||||
<div className="legal-form-row">
|
<div className="field-row">
|
||||||
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
|
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
|
||||||
<div className="form-group">
|
<Field label="pro" htmlFor="contract-costInterval">
|
||||||
<label className="form-label">pro</label>
|
<select className="select" id="contract-costInterval" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
|
||||||
<select className="form-control" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
|
|
||||||
<option value="month">Monat</option>
|
<option value="month">Monat</option>
|
||||||
<option value="year">Jahr</option>
|
<option value="year">Jahr</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
|
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
|
||||||
<div className="legal-form-row">
|
<div className="field-row">
|
||||||
{input('termMonths', 'Laufzeit (Monate, 0 = jederzeit kündbar)', { type: 'number', min: 0 })}
|
{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 })}
|
{input('renewalMonths', 'Verlängert sich um (Monate, 0 = endet)', { type: 'number', min: 0 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="legal-form-row">
|
<div className="field-row">
|
||||||
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
|
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
|
||||||
<div className="form-group">
|
<Field label="Einheit" htmlFor="contract-noticeUnit">
|
||||||
<label className="form-label">Einheit</label>
|
<select className="select" id="contract-noticeUnit" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
|
||||||
<select className="form-control" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
|
|
||||||
<option value="months">Monate</option>
|
<option value="months">Monate</option>
|
||||||
<option value="days">Tage</option>
|
<option value="days">Tage</option>
|
||||||
</select>
|
</select>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Field label="Notiz" htmlFor="contract-note">
|
||||||
<div className="form-group">
|
<textarea className="textarea" id="contract-note" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
|
||||||
<label className="form-label">Notiz</label>
|
</Field>
|
||||||
<textarea className="form-control" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
|
<Field label="Dateien hinzufügen" htmlFor="contract-files">
|
||||||
</div>
|
<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])} />
|
||||||
<div className="form-group">
|
</Field>
|
||||||
<label className="form-label">Dateien hinzufügen</label>
|
</Dialog>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Badge, Button, Card } from '../ui'
|
import { Badge, Card } from '../ui'
|
||||||
import { currentVersion, formatDate } from '../../lib/legal'
|
import { currentVersion, formatDate } from '../../lib/legal'
|
||||||
import VersionForm from './VersionForm'
|
import VersionForm from './VersionForm'
|
||||||
import { FileLinks } from './legalUi'
|
import { FileLinks } from './legalUi'
|
||||||
@@ -21,8 +21,8 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="legal-grid">
|
<div className="legal-docs">
|
||||||
{actionError && <div className="legal-error">{actionError}</div>}
|
{actionError && <div className="msg err legal-msg" role="alert">{actionError}</div>}
|
||||||
{documents.map((doc) => {
|
{documents.map((doc) => {
|
||||||
const own = versions.filter((v) => v.documentKey === doc.key).sort(byNewest)
|
const own = versions.filter((v) => v.documentKey === doc.key).sort(byNewest)
|
||||||
const current = currentVersion(own)
|
const current = currentVersion(own)
|
||||||
@@ -33,9 +33,14 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
|
|||||||
key={doc.$id}
|
key={doc.$id}
|
||||||
className="legal-doc"
|
className="legal-doc"
|
||||||
title={doc.title}
|
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>}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="legal-doc-summary"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setOpen(expanded ? null : doc.key)}
|
||||||
>
|
>
|
||||||
<div className="legal-doc-summary" onClick={() => setOpen(expanded ? null : doc.key)} role="button">
|
|
||||||
{current ? (
|
{current ? (
|
||||||
<Badge tone="ok">{current.version} · gilt seit {formatDate(current.validFrom)}</Badge>
|
<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) => (
|
{drafts.map((d) => (
|
||||||
<Badge key={d.$id} tone="muted">Entwurf {d.version}</Badge>
|
<Badge key={d.$id} tone="muted">Entwurf {d.version}</Badge>
|
||||||
))}
|
))}
|
||||||
<span className="faint legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
|
<span className="legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
|
||||||
</div>
|
</button>
|
||||||
{doc.liveUrl && (
|
{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 && (
|
{expanded && (
|
||||||
<ol className="legal-history">
|
<ol className="legal-history">
|
||||||
{own.length === 0 && <li className="faint">Noch keine Versionen eingetragen.</li>}
|
{own.length === 0 && <li className="faint">Noch keine Versionen eingetragen.</li>}
|
||||||
{own.map((v) => (
|
{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">
|
<div className="legal-version-head">
|
||||||
<strong>{v.version}</strong>
|
<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 === 'current' && <Badge tone="ok">gültig</Badge>}
|
||||||
{v.status === 'draft' && <Badge tone="muted">Entwurf</Badge>}
|
{v.status === 'draft' && <Badge tone="muted">Entwurf</Badge>}
|
||||||
{v.status === 'draft' && (
|
{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>
|
</div>
|
||||||
{v.changeNote && <div className="legal-note">{v.changeNote}</div>}
|
{v.changeNote && <div className="legal-note">{v.changeNote}</div>}
|
||||||
|
|||||||
@@ -1,39 +1,12 @@
|
|||||||
import { Fragment, useState } from 'react'
|
import { Fragment, useState } from 'react'
|
||||||
import { Button, EmptyState } from '../ui'
|
import { Badge, Button, EmptyState } from '../ui'
|
||||||
import { deadlineTone, formatDate, nextCancellationDate } from '../../lib/legal'
|
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 ContractForm from './ContractForm'
|
||||||
import { FileLinks, todayIso } from './legalUi'
|
import { FileLinks, todayIso } from './legalUi'
|
||||||
|
|
||||||
const TONE_ORDER = { red: 0, orange: 1, normal: 2, missing: 3, grey: 4 }
|
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 }) {
|
export default function ProviderContractsTab({ contracts, fileNames, saveContract, deleteContract }) {
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
@@ -77,55 +50,83 @@ export default function ProviderContractsTab({ contracts, fileNames, saveContrac
|
|||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<Button variant="primary" onClick={() => setEditing('new')}>Neuer Vertrag</Button>
|
<Button variant="primary" onClick={() => setEditing('new')}>Neuer Vertrag</Button>
|
||||||
</div>
|
</div>
|
||||||
{actionError && <div className="legal-error">{actionError}</div>}
|
{actionError && <div className="msg err legal-msg" role="alert">{actionError}</div>}
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<EmptyState title="Noch keine Verträge eingetragen" />
|
<EmptyState title="Noch keine Verträge eingetragen" />
|
||||||
) : (
|
) : (
|
||||||
<table className="ui-table legal-contracts">
|
<table className="table legal-contracts">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map(({ c, result, tone }) => (
|
{rows.map(({ c, result }) => {
|
||||||
|
const view = deadlineView(result, today)
|
||||||
|
const toggle = () => setOpen(open === c.$id ? null : c.$id)
|
||||||
|
return (
|
||||||
<Fragment key={c.$id}>
|
<Fragment key={c.$id}>
|
||||||
<tr className={`legal-tone-${tone}`} onClick={() => setOpen(open === c.$id ? null : c.$id)}>
|
<tr className="row" onClick={(e) => { if (isRowActivation(e)) toggle() }}>
|
||||||
<td><strong>{c.provider}</strong></td>
|
<td>
|
||||||
<td>{c.purpose || '–'}</td>
|
<div className="cell-title">
|
||||||
<td>{costLabel(c)}</td>
|
<button type="button" className="link" aria-expanded={open === c.$id} onClick={toggle}>{c.provider}</button>
|
||||||
<td>{termLabel(c)}</td>
|
</div>
|
||||||
<td>{noticeLabel(c)}</td>
|
</td>
|
||||||
<td className="legal-deadline">{deadlineLabel(result)}</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>
|
</tr>
|
||||||
{open === c.$id && (
|
{open === c.$id && (
|
||||||
<tr className="legal-detail">
|
<tr className="legal-detail">
|
||||||
<td colSpan={6}>
|
<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.startDate && <div>Beginn: {formatDate(c.startDate)}</div>}
|
||||||
{c.note && <div className="legal-note">{c.note}</div>}
|
{c.note && <div className="legal-note">{c.note}</div>}
|
||||||
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
|
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
|
||||||
<div className="flex gap-2 legal-actions">
|
<div className="legal-actions">
|
||||||
<Button size="sm" onClick={() => setEditing(c)}>Bearbeiten</Button>
|
<button type="button" className="link" onClick={() => setEditing(c)}>Bearbeiten</button>
|
||||||
{!c.cancelledAt && cancelling !== c.$id && (
|
{!c.cancelledAt && cancelling !== c.$id && (
|
||||||
<Button size="sm" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
|
<button type="button" className="link" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
|
||||||
Als gekündigt markieren
|
Als gekündigt markieren
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{cancelling === c.$id && (
|
{cancelling === c.$id && (
|
||||||
<span className="flex gap-2 items-center">
|
<span className="legal-cancel">
|
||||||
<label>Endet am</label>
|
<label htmlFor={`cancel-${c.$id}`}>Endet am</label>
|
||||||
<input type="date" className="form-control" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
|
<input id={`cancel-${c.$id}`} type="date" className="input" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
|
||||||
<Button size="sm" variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
|
<Button variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
|
||||||
<Button size="sm" onClick={() => setCancelling(null)}>Abbrechen</Button>
|
<Button onClick={() => setCancelling(null)}>Abbrechen</Button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="danger" onClick={() => remove(c)}>Löschen</Button>
|
<Button variant="danger" onClick={() => remove(c)}>Löschen</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { Button } from '../ui'
|
import { Button, Dialog, Field } from '../ui'
|
||||||
import { todayIso } from './legalUi'
|
import { todayIso } from './legalUi'
|
||||||
|
|
||||||
export default function VersionForm({ title, onSave, onClose }) {
|
export default function VersionForm({ title, onSave, onClose }) {
|
||||||
@@ -8,6 +8,8 @@ export default function VersionForm({ title, onSave, onClose }) {
|
|||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
@@ -24,41 +26,39 @@ export default function VersionForm({ title, onSave, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
|
as="form"
|
||||||
<div className="overlay-content">
|
title={`Neue Version: ${title}`}
|
||||||
<h2 className="mb-2">Neue Version: {title}</h2>
|
busy={saving}
|
||||||
{error && <div className="legal-error">{error}</div>}
|
onClose={onClose}
|
||||||
<form onSubmit={submit}>
|
onSubmit={submit}
|
||||||
<div className="form-group">
|
footer={
|
||||||
<label className="form-label">Version</label>
|
<>
|
||||||
<input className="form-control" name="version" value={form.version} onChange={set('version')} placeholder="z. B. v6" required />
|
<span className="grow" />
|
||||||
</div>
|
<Button type="button" onClick={onClose} disabled={saving}>Abbrechen</Button>
|
||||||
<div className="form-group">
|
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
|
||||||
<label className="form-label">Status</label>
|
</>
|
||||||
<select className="form-control" name="status" value={form.status} onChange={set('status')}>
|
}
|
||||||
|
>
|
||||||
|
{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="current">Gültig</option>
|
||||||
<option value="draft">Entwurf (noch nicht live)</option>
|
<option value="draft">Entwurf (noch nicht live)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="form-group">
|
<Field label="Gültig ab" htmlFor="version-validFrom">
|
||||||
<label className="form-label">Gültig ab</label>
|
<input className="input" id="version-validFrom" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
|
||||||
<input className="form-control" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
|
</Field>
|
||||||
</div>
|
<Field label="Was hat sich geändert?" htmlFor="version-changeNote">
|
||||||
<div className="form-group">
|
<textarea className="textarea" id="version-changeNote" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
|
||||||
<label className="form-label">Was hat sich geändert?</label>
|
</Field>
|
||||||
<textarea className="form-control" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
|
<Field label="Dateien (PDF, HTML, Bild, Word)" htmlFor="version-files">
|
||||||
</div>
|
<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])} />
|
||||||
<div className="form-group">
|
</Field>
|
||||||
<label className="form-label">Dateien (PDF, HTML, Bild, Word)</label>
|
</Dialog>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,9 @@ export function FileLinks({ fileIds = [], fileNames = {} }) {
|
|||||||
<span className="legal-files">
|
<span className="legal-files">
|
||||||
{fileIds.map((id) => (
|
{fileIds.map((id) => (
|
||||||
<span key={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>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaSpinner } from 'react-icons/fa6'
|
import { Dialog, Field, Button, AutocompleteInput } from '../ui'
|
||||||
import { Card, Button, AutocompleteInput } from '../ui'
|
|
||||||
import { useCustomers } from '../../hooks/useCustomers'
|
import { useCustomers } from '../../hooks/useCustomers'
|
||||||
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
import { createOffer, getOfferContext } from '../../lib/teamApi'
|
import { createOffer, getOfferContext } from '../../lib/teamApi'
|
||||||
import { customerPickLabel, findCustomerByLabel } from '../../lib/chatFormat'
|
import { customerPickLabel, findCustomerByLabel } from '../../lib/chatFormat'
|
||||||
import { createdMessage, evaluateOfferForm, offerConfirm } from '../../lib/offerFormat'
|
import { createdMessage, evaluateOfferForm, offerConfirm } from '../../lib/offerFormat'
|
||||||
import '../../styles/admin-areas.css'
|
|
||||||
|
|
||||||
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
|
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 [ctx, setCtx] = useState(null) // { customerId, data } | { customerId, error }
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCtx(null)
|
setCtx(null)
|
||||||
@@ -83,62 +84,70 @@ export default function OfferForm({ fixedCustomerId, fixedCustomerName, onCreate
|
|||||||
}
|
}
|
||||||
|
|
||||||
let previewBox
|
let previewBox
|
||||||
if (!customerId) previewBox = <div>Bitte einen Kunden aus der Liste wählen.</div>
|
if (!customerId) previewBox = <p>Bitte einen Kunden aus der Liste wählen.</p>
|
||||||
else if (ctx?.error && ctx.customerId === customerId) previewBox = <div className="area-danger">{ctx.error}</div>
|
else if (ctx?.error && ctx.customerId === customerId) previewBox = <p className="msg err">{ctx.error}</p>
|
||||||
else if (!ready) previewBox = <div>Lade Kunde…</div>
|
else if (!ready) previewBox = <p>Lade Kunde…</p>
|
||||||
else {
|
else {
|
||||||
previewBox = (
|
previewBox = (
|
||||||
<>
|
<>
|
||||||
{evaluation.errors.map((l) => <div key={l} className="area-danger">{l}</div>)}
|
{evaluation.errors.map((l) => <p key={l} className="msg err">{l}</p>)}
|
||||||
{evaluation.previewLines.map((l) => <div key={l}>{l}</div>)}
|
{evaluation.previewLines.map((l) => <p key={l}>{l}</p>)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="offer-form">
|
<Dialog
|
||||||
<h3 style={{ marginTop: 0 }}>Neues Angebot</h3>
|
title="Neues Angebot"
|
||||||
<div className="credit-calc">
|
onClose={onCancel}
|
||||||
<label style={{ gridColumn: '1 / -1' }}>Kunde
|
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
|
{fixedCustomerId
|
||||||
? <strong style={{ color: 'var(--text)' }}>{fixedCustomerName || 'Kunde'}</strong>
|
? <p className="strong">{fixedCustomerName || 'Kunde'}</p>
|
||||||
: <AutocompleteInput value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
|
: <AutocompleteInput id="offer-customer" value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
|
||||||
</label>
|
</Field>
|
||||||
<label>Produkt
|
<div className="field-row">
|
||||||
<select className="form-control" value={productKey} onChange={(e) => setProductKey(e.target.value)}>
|
<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="wartung">Wartungs-Abo</option>
|
||||||
<option value="hosting">Hosting-Abo</option>
|
<option value="hosting">Hosting-Abo</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</Field>
|
||||||
<label>Preis pro Monat im Angebot (€)
|
<Field label="Preis pro Monat im Angebot (€)" htmlFor="offer-price">
|
||||||
<input className="form-control" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
|
<input id="offer-price" className="input" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
<label>für
|
</div>
|
||||||
<select className="form-control" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
|
<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>)}
|
{MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</Field>
|
||||||
<label>Angebot gültig bis
|
<Field label="Angebot gültig bis" htmlFor="offer-valid-until">
|
||||||
<input className="form-control" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
|
<input id="offer-valid-until" className="input" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<label className="credit-field">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)
|
<Field label="Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)" htmlFor="offer-note">
|
||||||
<input className="form-control" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
|
<input id="offer-note" className="input" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
<label className="credit-field">Interne Notiz (optional, sieht nur das Team)
|
<Field label="Interne Notiz (optional, sieht nur das Team)" htmlFor="offer-internal-note">
|
||||||
<input className="form-control" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
|
<input id="offer-internal-note" className="input" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
|
<label className="check">
|
||||||
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} /> Kunden per E-Mail über das Angebot informieren
|
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} /> Kunden per E-Mail über das Angebot informieren
|
||||||
</label>
|
</label>
|
||||||
<div className="area-summary">{previewBox}</div>
|
<div>{previewBox}</div>
|
||||||
<div className="area-inline">
|
{error && <p className="msg err" role="alert" ref={errorRef}>{error}</p>}
|
||||||
<Button variant="primary" onClick={save} disabled={!evaluation || evaluation.errors.length > 0 || busy}>
|
</Dialog>
|
||||||
{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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FaSpinner } from 'react-icons/fa6'
|
import { Badge, Button, EmptyState, Tabs } from '../ui'
|
||||||
import { Card, Badge, Button } from '../ui'
|
|
||||||
import { listOffers, notifyOffer, withdrawOffer } from '../../lib/teamApi'
|
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 { 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 }) {
|
export default function OfferList({ customerId, reloadKey, onMessage, onChanged }) {
|
||||||
const [filter, setFilter] = useState('open')
|
const [filter, setFilter] = useState('open')
|
||||||
const [offers, setOffers] = useState(null)
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="area-filter seg">
|
<Tabs tabs={[{ id: 'open', label: 'Offen & aktiv' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
|
||||||
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen & aktiv</button>
|
{error && <div className="msg err" role="alert">{error}</div>}
|
||||||
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
|
{!offers && !error && <div className="loading">Wird geladen …</div>}
|
||||||
</div>
|
{offers && shown.length === 0 && (
|
||||||
<Card pad={false}>
|
<EmptyState title={filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} />
|
||||||
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
|
)}
|
||||||
{!offers && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
|
{offers && shown.length > 0 && (
|
||||||
{offers && (
|
<table className="table table-wrap">
|
||||||
<div className="area-rows">
|
<thead>
|
||||||
<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>
|
<tr>
|
||||||
{shown.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</span></div>}
|
<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) => {
|
{shown.map((o) => {
|
||||||
const st = offerStatus(o.status)
|
const st = offerStatus(o.status)
|
||||||
const discount = discountLine(o)
|
const discount = discountLine(o)
|
||||||
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
|
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 (
|
return (
|
||||||
<div key={o.id} className="area-row offer-row">
|
<tr key={o.id}>
|
||||||
<span>{fmtDateTime(o.createdAt)}<br /><span className="area-muted">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</span></span>
|
<td className="hide-md">
|
||||||
<span>
|
{fmtDateTime(o.createdAt)}
|
||||||
<strong>{o.customerName}</strong>
|
<div className="cell-sub">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</div>
|
||||||
{o.customerEmail && <><br /><span className="area-muted">{o.customerEmail}</span></>}
|
</td>
|
||||||
{o.customerCode && <><br /><span className="area-muted">Kd.-Nr. {o.customerCode}</span></>}
|
<td>
|
||||||
</span>
|
<div className="cell-title">{o.customerName}</div>
|
||||||
<span>
|
{o.customerEmail && <div className="cell-sub">{o.customerEmail}</div>}
|
||||||
<span className="area-muted">{productLabelOf(o.productKey)}</span><br />{o.text}
|
{o.customerCode && <div className="cell-sub">Kd.-Nr. {o.customerCode}</div>}
|
||||||
<br /><span className="area-muted">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</span>
|
</td>
|
||||||
{discount && <><br /><span className="area-muted">{discount}</span></>}
|
<td>
|
||||||
{o.internalNote && <><br /><span className="area-muted"><em>intern: {o.internalNote}</em></span></>}
|
<div className="cell-sub">{productLabelOf(o.productKey)}</div>
|
||||||
</span>
|
{o.text}
|
||||||
<span>{o.scope === 'laufend' ? <span className="area-muted">laufendes Abo</span> : fmtDate(o.validUntil)}</span>
|
<div className="cell-sub">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</div>
|
||||||
<span>
|
{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>
|
<Badge tone={st.tone}>{st.label}</Badge>
|
||||||
{sub && <><br /><span className="area-muted">{sub}</span></>}
|
{sub && <div className="cell-sub">{sub}</div>}
|
||||||
<br /><span className="area-muted">Stripe:</span>{' '}
|
<div className="cell-sub">
|
||||||
{o.stripeCouponUrl ? <a href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
|
Stripe: {o.stripeCouponUrl ? <a className="link" href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
|
||||||
{o.stripeSubscriptionUrl && <> · <a href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
|
{o.stripeSubscriptionUrl && <> · <a className="link" 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>
|
|
||||||
)}
|
|
||||||
{['offen', 'abgelaufen'].includes(o.status) && (
|
|
||||||
<Button size="sm" variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
{canWithdraw && <Button variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</tbody>
|
||||||
|
</table>
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaPlus } from 'react-icons/fa6'
|
|
||||||
import { Button } from '../ui'
|
import { Button } from '../ui'
|
||||||
import OfferList from './OfferList'
|
import OfferList from './OfferList'
|
||||||
import OfferForm from './OfferForm'
|
import OfferForm from './OfferForm'
|
||||||
@@ -13,23 +12,19 @@ export default function OffersView({ customerId, customerName }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{message && <div className="area-message">{message}</div>}
|
{message && <p className="msg ok mb-2" role="status">{message}</p>}
|
||||||
{!showForm && (
|
<div className="toolbar">
|
||||||
<div style={{ marginBottom: 12 }}>
|
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}>Neues Angebot</Button>
|
||||||
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}><FaPlus /> Neues Angebot</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<OfferList customerId={customerId} reloadKey={reloadKey} onMessage={setMessage} onChanged={reload} />
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<OfferForm
|
<OfferForm
|
||||||
fixedCustomerId={customerId}
|
fixedCustomerId={customerId}
|
||||||
fixedCustomerName={customerName}
|
fixedCustomerName={customerName}
|
||||||
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
|
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
|
||||||
onCancel={() => setShowForm(false)}
|
onCancel={() => setShowForm(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<OfferList customerId={customerId} reloadKey={reloadKey} onMessage={setMessage} onChanged={reload} />
|
|
||||||
</div>
|
</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 { useMemo, useState } from 'react'
|
||||||
import { formatBytes, formatDelta, formatPct } from '../../lib/serverpulsFormat'
|
import { formatBytes, formatDelta, formatPct } from '../../lib/serverpulsFormat'
|
||||||
|
import { isRowActivation } from '../../lib/rowClick'
|
||||||
import Sparkline from './Sparkline'
|
import Sparkline from './Sparkline'
|
||||||
|
|
||||||
const COLS = [
|
// Das Vorzeichen steckt in d.text, die Richtung färbt den Text (rot = gestiegen, grün = gesunken)
|
||||||
{ 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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
function Delta({ pct }) {
|
function Delta({ pct }) {
|
||||||
const d = formatDelta(pct)
|
const d = formatDelta(pct)
|
||||||
const arrow = d.dir === 'up' ? '▲ ' : d.dir === 'down' ? '▼ ' : ''
|
return <span className={`sp-delta sp-delta-${d.dir}`}>{d.text}</span>
|
||||||
return <span className={`sp-delta sp-delta-${d.dir}`}>{arrow}{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 }) {
|
export default function AppsTable({ rows, selectedId, onSelect }) {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [sort, setSort] = useState({ key: 'sizeBytes', dir: 'desc' })
|
const [sort, setSort] = useState({ key: 'sizeBytes', dir: 'desc' })
|
||||||
@@ -39,52 +43,71 @@ export default function AppsTable({ rows, selectedId, onSelect }) {
|
|||||||
const toggle = (key) =>
|
const toggle = (key) =>
|
||||||
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }))
|
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 (
|
return (
|
||||||
<div className="sp-apps" data-testid="sp-apps">
|
<div data-testid="sp-apps">
|
||||||
|
<div className="toolbar">
|
||||||
<input
|
<input
|
||||||
className="sp-search"
|
className="input search"
|
||||||
data-testid="sp-apps-search"
|
data-testid="sp-apps-search"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Projekt suchen …"
|
placeholder="Projekt suchen …"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="sp-table-wrap">
|
</div>
|
||||||
<table className="sp-table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{COLS.map((c) => (
|
{COLS.map((c) => (
|
||||||
<th key={c.key} onClick={() => toggle(c.key)} className="sp-sortable" data-sort={c.key}>
|
<th
|
||||||
{c.label}{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}
|
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>
|
||||||
))}
|
))}
|
||||||
<th>30 Tage</th>
|
<th className="hide-sm">30 Tage</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.map((r) => (
|
{list.map((r) => {
|
||||||
|
const selected = selectedId === r.id
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={r.id}
|
key={r.id}
|
||||||
|
className={`row${selected ? ' sp-row-active' : ''}`}
|
||||||
data-testid="sp-app-row"
|
data-testid="sp-app-row"
|
||||||
data-app-id={r.id}
|
data-app-id={r.id}
|
||||||
className={`sp-row ${selectedId === r.id ? 'sp-row-active' : ''}`}
|
onClick={(e) => {
|
||||||
onClick={() => onSelect(selectedId === r.id ? null : r.id)}
|
if (isRowActivation(e)) pick(r.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="sp-name">{r.name}</td>
|
<td className="sp-name">
|
||||||
<td>{formatBytes(r.sizeBytes)}</td>
|
<button type="button" className="link" aria-pressed={selected} onClick={() => pick(r.id)}>
|
||||||
<td><Delta pct={r.delta7d} /></td>
|
{r.name}
|
||||||
<td><Delta pct={r.delta30d} /></td>
|
</button>
|
||||||
<td>{r.cpuPct === null ? '–' : formatPct(r.cpuPct)}</td>
|
</td>
|
||||||
<td>{formatBytes(r.memBytes)}</td>
|
{COLS.slice(1).map((c) => (
|
||||||
<td><Sparkline points={r.spark30d} width={90} height={24} /></td>
|
<td key={c.key} className={cellClass(c)}>{c.cell(r)}</td>
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
|
<td className="hide-sm"><Sparkline points={r.spark30d} width={90} height={24} /></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{list.length === 0 && (
|
{list.length === 0 && (
|
||||||
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
|
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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 }) {
|
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 (
|
return (
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist" ref={listRef}>
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export { default as Tabs } from './Tabs'
|
|||||||
export { default as EmptyState } from './EmptyState'
|
export { default as EmptyState } from './EmptyState'
|
||||||
export { default as Field } from './Field'
|
export { default as Field } from './Field'
|
||||||
export { default as Stat } from './Stat'
|
export { default as Stat } from './Stat'
|
||||||
export { default as Kpi } from './Kpi'
|
|
||||||
export { default as AutocompleteInput } from './AutocompleteInput'
|
export { default as AutocompleteInput } from './AutocompleteInput'
|
||||||
export { default as DateTimeInput } from './DateTimeInput'
|
export { default as DateTimeInput } from './DateTimeInput'
|
||||||
export { default as DurationSelect } from './DurationSelect'
|
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 }
|
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 = {
|
const HISTORY_LABELS = {
|
||||||
eingereicht: 'Antrag eingereicht',
|
eingereicht: 'Antrag eingereicht',
|
||||||
rueckfrage: 'Rückfrage an den Kunden',
|
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)
|
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function customerStage(c) {
|
export { customerStage, isArchived, filterCustomers, stageCounts } from './customerList'
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kunde archivieren / wiederherstellen. */
|
/** Kunde archivieren / wiederherstellen. */
|
||||||
export async function setCustomerArchived(customer, archived) {
|
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.
|
// 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.
|
// "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 }
|
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())
|
const todayBerlin = () => berlinYmd(new Date())
|
||||||
|
|
||||||
export function offerPreviewText(price, months, regular, running) {
|
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' }
|
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
|
// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins
|
||||||
const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything']
|
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)
|
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) ---
|
// --- Zeitachsen (Europe/Berlin, de-DE) ---
|
||||||
const TZ = 'Europe/Berlin'
|
const TZ = 'Europe/Berlin'
|
||||||
const dtf = (opts) => new Intl.DateTimeFormat('de-DE', { timeZone: TZ, ...opts })
|
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.
|
// 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)
|
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) =>
|
export const failedText = (f) =>
|
||||||
`${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}`
|
`${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 */
|
/* Admin: Mitarbeiter-Tabelle, Zugangsdaten, Dialog. Karten, Rahmen und Schatten gibt es nicht mehr; Abstände zwischen den */
|
||||||
.page.admin-page {
|
/* Abschnitten liefert .page section + section (global.css). AdminPage.css lädt vor global.css, darum haben Regeln, die eine */
|
||||||
max-width: 1480px;
|
/* 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-page {
|
.admin-notice { margin-bottom: 16px; }
|
||||||
padding-bottom: 48px;
|
.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 {
|
/* Admin-Haken im Dialog: Titel und Erklärung untereinander, der Haken oben links */
|
||||||
display: flex;
|
.check.admin-check { align-items: flex-start; }
|
||||||
align-items: flex-end;
|
.admin-check small { display: block; margin-top: 2px; font-size: 13px; line-height: 18px; color: var(--text-2); }
|
||||||
justify-content: space-between;
|
.admin-check.is-disabled { cursor: not-allowed; opacity: .55; }
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header h1 {
|
/* 403 */
|
||||||
margin: 4px 0 6px;
|
.admin-access-denied { max-width: 540px; margin: 12vh auto 0; }
|
||||||
font-size: clamp(28px, 3vw, 38px);
|
.admin-access-denied > span { font-size: 12px; font-weight: 500; color: var(--red-fg); }
|
||||||
line-height: 1.1;
|
.admin-access-denied h1 { margin: 4px 0 8px; font-size: 28px; line-height: 34px; font-weight: 700; letter-spacing: -.02em; }
|
||||||
letter-spacing: -0.03em;
|
.admin-access-denied p { color: var(--text-2); }
|
||||||
}
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
}
|
|
||||||
+175
-117
@@ -1,15 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import {
|
|
||||||
FaFloppyDisk,
|
|
||||||
FaPlus,
|
|
||||||
FaSpinner,
|
|
||||||
FaTrash,
|
|
||||||
FaXmark,
|
|
||||||
} from 'react-icons/fa6'
|
|
||||||
import { FaEdit } from 'react-icons/fa'
|
|
||||||
import CopyableCredential from '../components/CopyableCredential'
|
import CopyableCredential from '../components/CopyableCredential'
|
||||||
|
import { Badge, Button, Card, Dialog, EmptyState, Field, PageHeader, Tabs } from '../components/ui'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useEmployees } from '../hooks/useEmployees'
|
import { useEmployees } from '../hooks/useEmployees'
|
||||||
|
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||||||
|
import { validateEmployeeForm } from '../lib/employeeForm'
|
||||||
import './AdminPage.css'
|
import './AdminPage.css'
|
||||||
|
|
||||||
const EMPTY_EMPLOYEE = {
|
const EMPTY_EMPLOYEE = {
|
||||||
@@ -22,7 +17,11 @@ const EMPTY_EMPLOYEE = {
|
|||||||
|
|
||||||
function Notice({ notice }) {
|
function Notice({ notice }) {
|
||||||
if (!notice?.text) return null
|
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() {
|
export default function AdminPage() {
|
||||||
@@ -40,9 +39,14 @@ export default function AdminPage() {
|
|||||||
const [activeSection, setActiveSection] = useState('employees')
|
const [activeSection, setActiveSection] = useState('employees')
|
||||||
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
|
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
|
||||||
const [editingEmployee, setEditingEmployee] = useState(null)
|
const [editingEmployee, setEditingEmployee] = useState(null)
|
||||||
|
const [formOpen, setFormOpen] = useState(false)
|
||||||
|
const [formError, setFormError] = useState(null)
|
||||||
const [employeeBusy, setEmployeeBusy] = useState(false)
|
const [employeeBusy, setEmployeeBusy] = useState(false)
|
||||||
const [createdCredential, setCreatedCredential] = useState(null)
|
const [createdCredential, setCreatedCredential] = useState(null)
|
||||||
const [notice, setNotice] = 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) => {
|
const showNotice = (type, text) => {
|
||||||
setNotice({ 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)
|
setEmployeeForm(EMPTY_EMPLOYEE)
|
||||||
setEditingEmployee(null)
|
setEditingEmployee(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitEmployee = async (event) => {
|
const createNew = () => {
|
||||||
event.preventDefault()
|
setEmployeeForm(EMPTY_EMPLOYEE)
|
||||||
if (!employeeForm.displayName.trim() || !employeeForm.email.trim()) {
|
setEditingEmployee(null)
|
||||||
showNotice('error', 'Name und E-Mail sind erforderlich.')
|
setFormError(null)
|
||||||
return
|
setFormOpen(true)
|
||||||
}
|
|
||||||
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 editEmployee = (employee) => {
|
const editEmployee = (employee) => {
|
||||||
@@ -109,6 +91,36 @@ export default function AdminPage() {
|
|||||||
shortcode: employee.shortcode || '',
|
shortcode: employee.shortcode || '',
|
||||||
isAdmin: Boolean(employee.isAdmin),
|
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) => {
|
const removeEmployee = async (employee) => {
|
||||||
@@ -120,107 +132,153 @@ export default function AdminPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setField = (key) => (event) => setEmployeeForm({ ...employeeForm, [key]: event.target.value })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page admin-page">
|
<div className="page admin-page">
|
||||||
<header className="admin-header">
|
<PageHeader
|
||||||
<div>
|
eyebrow="Systemverwaltung"
|
||||||
<span className="admin-eyebrow">Systemverwaltung</span>
|
title="Admin"
|
||||||
<h1>Admin</h1>
|
subtitle="Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“."
|
||||||
<p>Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“.</p>
|
actions={
|
||||||
</div>
|
<>
|
||||||
<div className="admin-current-user">
|
<span className="muted small">Angemeldet als <strong>{user?.name || user?.email}</strong></span>
|
||||||
<span>Angemeldet als</span>
|
<Button variant="primary" type="button" onClick={createNew}>Mitarbeiter anlegen</Button>
|
||||||
<strong>{user?.name || user?.email}</strong>
|
</>
|
||||||
</div>
|
}
|
||||||
</header>
|
/>
|
||||||
|
|
||||||
<Notice notice={notice} />
|
<Notice notice={notice} />
|
||||||
|
|
||||||
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
<Tabs
|
||||||
{[
|
tabs={[{ id: 'employees', label: `Mitarbeiter (${employees.length})` }]}
|
||||||
['employees', `Mitarbeiter (${employees.length})`],
|
active={activeSection}
|
||||||
].map(([id, label]) => (
|
onChange={setActiveSection}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
key={id}
|
|
||||||
className={activeSection === id ? 'active' : ''}
|
|
||||||
onClick={() => setActiveSection(id)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{activeSection === 'employees' && (
|
{activeSection === 'employees' && (
|
||||||
<section>
|
<>
|
||||||
{createdCredential && (
|
{createdCredential && (
|
||||||
<div className="admin-credential-result">
|
<Card
|
||||||
<div><strong>Login wurde angelegt</strong><span>Diese Zugangsdaten jetzt sicher weitergeben.</span></div>
|
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.email} label="E-Mail" />
|
||||||
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
|
<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>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
<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>}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<section className="admin-list-card">
|
<Card title="Mitarbeiterkonten">
|
||||||
<div className="admin-section-head"><div><h2>Mitarbeiterkonten</h2><span>Aktive Logins für das Ticketsystem</span></div></div>
|
<p className="muted small admin-lead">Aktive Logins für das Ticketsystem</p>
|
||||||
{employeesError && <Notice notice={{ type: 'error', text: employeesError }} />}
|
{employeesError && <div className="msg err" role="alert">{employeesError}</div>}
|
||||||
{employeesLoading ? (
|
{employeesLoading ? (
|
||||||
<div className="admin-loading"><FaSpinner className="spinner" /> Mitarbeiter werden geladen …</div>
|
<div className="loading">Mitarbeiter werden geladen …</div>
|
||||||
) : employees.length === 0 ? (
|
) : employees.length === 0 ? (
|
||||||
<div className="admin-empty-state"><h3>Noch keine Mitarbeiter</h3><p>Lege links den ersten Login an.</p></div>
|
<EmptyState title="Noch keine Mitarbeiter" hint="Lege mit „Mitarbeiter anlegen“ den ersten Login an." />
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-table-wrap">
|
<table className="table">
|
||||||
<table className="admin-table">
|
<thead>
|
||||||
<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>
|
<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>
|
<tbody>
|
||||||
{employees.map((employee) => (
|
{employees.map((employee) => (
|
||||||
<tr key={employee.$id}>
|
<tr key={employee.$id}>
|
||||||
<td><strong>{employee.displayName || '–'}</strong></td>
|
<td><strong>{employee.displayName || '–'}</strong></td>
|
||||||
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
|
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
|
||||||
<td><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
|
<td className="hide-sm"><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
|
||||||
<td><span className={`admin-role ${employee.isAdmin ? 'admin' : ''}`}>{employee.isAdmin ? 'Admin' : 'Mitarbeiter'}</span></td>
|
<td className="hide-sm">
|
||||||
<td><CopyableCredential value={employee.userId} label="User-ID" /></td>
|
{employee.isAdmin ? <Badge tone="ok">Admin</Badge> : <span className="muted">Mitarbeiter</span>}
|
||||||
<td className="admin-row-actions">
|
</td>
|
||||||
<button type="button" className="admin-icon-button" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}><FaEdit /></button>
|
<td className="hide-sm"><CopyableCredential value={employee.userId} label="User-ID" /></td>
|
||||||
{employee.userId !== user?.$id && <button type="button" className="admin-icon-button danger" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}><FaTrash /></button>}
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
</div>
|
</>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+103
-85
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { FaSpinner, FaPlus, FaArrowLeft, FaArrowRight, FaCheck } from 'react-icons/fa6'
|
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
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 { suggestSubdomain, slugify } from '../lib/subdomain'
|
||||||
import { isArchived } from '../lib/leads'
|
import { isArchived } from '../lib/leads'
|
||||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
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'
|
import { CreateCustomerForm } from './CustomersPage'
|
||||||
|
|
||||||
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
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) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" />
|
<PageHeader crumb={[{ label: 'Projekte', to: '/projects' }]} title="Projekt zuordnen" />
|
||||||
<Card><div className="text-red">{loadError}</div></Card>
|
<p className="msg err" role="alert">{loadError}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdLine = project.$createdAt && (
|
||||||
|
<> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>
|
||||||
|
)
|
||||||
const header = (
|
const header = (
|
||||||
<Card>
|
<PageHeader
|
||||||
<div style={{ fontWeight: 700, fontSize: 18 }}>{repoName || project.projectName}</div>
|
crumb={[{ label: 'Projekte', to: '/projects' }, { label: repoName || project.projectName }]}
|
||||||
<div className="muted" style={{ marginTop: 4 }}>
|
title="Projekt zuordnen"
|
||||||
{repoUrl && <a href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
|
subtitle={(repoUrl || createdLine) ? (
|
||||||
{project.$createdAt && <> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>}
|
<>
|
||||||
</div>
|
{repoUrl && <a className="link" href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
|
||||||
</Card>
|
{createdLine}
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" />
|
|
||||||
{header}
|
{header}
|
||||||
<Card title="Zugeordnet, mit Hinweisen">
|
<Card title="Zugeordnet, mit Hinweisen">
|
||||||
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
|
<div className="assign-step">
|
||||||
|
<ul className="summary">
|
||||||
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
<Button variant="primary" onClick={() => navigate(done.path)}>Weiter <FaArrowRight /></Button>
|
<div className="row-buttons">
|
||||||
|
<Button type="button" variant="primary" onClick={() => navigate(done.path)}>Weiter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -215,16 +223,15 @@ export default function AssignProjectPage() {
|
|||||||
if (project.kind) {
|
if (project.kind) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" />
|
|
||||||
{header}
|
{header}
|
||||||
<Card>
|
<Card>
|
||||||
{notice && <div className="text-red" style={{ marginBottom: 8 }}>{notice}</div>}
|
<div className="assign-step">
|
||||||
<p style={{ marginTop: 0 }}>
|
{notice && <p className="msg err" role="alert">{notice}</p>}
|
||||||
|
<p>
|
||||||
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
|
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
|
||||||
</p>
|
</p>
|
||||||
{project.ticketId && (
|
{project.ticketId && <p><TicketLink ticketId={project.ticketId} /></p>}
|
||||||
<p style={{ marginBottom: 0 }}><TicketLink ticketId={project.ticketId} /></p>
|
</div>
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -236,45 +243,44 @@ export default function AssignProjectPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" subtitle={STEP_TITLES[state.step]} />
|
|
||||||
{header}
|
{header}
|
||||||
|
|
||||||
<Card title={STEP_TITLES[state.step]}>
|
<Card title={STEP_TITLES[state.step]}>
|
||||||
|
<div className="assign-step">
|
||||||
{state.step === 'kind' && (
|
{state.step === 'kind' && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 }}>
|
<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>
|
||||||
{kinds.map((k) => (
|
{kinds.map((k) => (
|
||||||
<Button
|
<button
|
||||||
key={k.key}
|
key={k.key}
|
||||||
variant={state.kind === k.key ? 'primary' : 'default'}
|
type="button"
|
||||||
style={{ padding: '18px 12px', fontSize: 16, fontWeight: 700 }}
|
aria-pressed={state.kind === k.key}
|
||||||
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
|
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
|
||||||
>
|
>
|
||||||
{k.label}
|
{k.label}
|
||||||
</Button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.step === 'customer?' && (
|
{state.step === 'customer?' && (
|
||||||
<div className="flex gap-2 wrap">
|
<div className="row-buttons">
|
||||||
<Button variant="primary" style={{ padding: '14px 28px' }}
|
<Button type="button" variant="primary"
|
||||||
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
|
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
|
||||||
<Button style={{ padding: '14px 28px' }}
|
<Button type="button"
|
||||||
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
|
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.step === 'customer' && (
|
{state.step === 'customer' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 wrap" style={{ marginBottom: 10 }}>
|
<div className="row-buttons">
|
||||||
<input className="form-control search-input" placeholder="Kunde suchen …" value={search}
|
<input className="input search" aria-label="Kunde suchen" placeholder="Kunde suchen …" value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
|
onChange={(e) => setSearch(e.target.value)} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button onClick={() => setShowNewCustomer((s) => !s)}><FaPlus /> Neuer Kunde</Button>
|
<Button type="button" onClick={() => setShowNewCustomer((s) => !s)}>Neuer Kunde</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showNewCustomer && isAdmin && (
|
{showNewCustomer && isAdmin && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<CreateCustomerForm
|
<CreateCustomerForm
|
||||||
onCancel={() => setShowNewCustomer(false)}
|
onCancel={() => setShowNewCustomer(false)}
|
||||||
onCreate={async (data) => {
|
onCreate={async (data) => {
|
||||||
@@ -286,66 +292,75 @@ export default function AssignProjectPage() {
|
|||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="list" style={{ maxHeight: 360, overflowY: 'auto' }}>
|
<div className="pick-list">
|
||||||
{filteredCustomers.slice(0, 100).map((c) => (
|
{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 }}
|
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 })}>
|
onClick={() => patch({ customerId: c.$id, ticketId: '', newTicket: null })}>
|
||||||
<div className="grow">
|
<span className="list-row-text">
|
||||||
<div style={{ fontWeight: 700 }}>{c.name}{c.code && <span className="faint" style={{ fontWeight: 400 }}> · {c.code}</span>}</div>
|
<span className="cell-title">{c.name}{c.code && <span className="faint"> · {c.code}</span>}</span>
|
||||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
|
<span className="cell-sub">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</span>
|
||||||
</div>
|
</span>
|
||||||
{state.customerId === c.$id && <FaCheck />}
|
{picked && <Badge>Ausgewählt</Badge>}
|
||||||
</div>
|
</button>
|
||||||
))}
|
)
|
||||||
{filteredCustomers.length === 0 && <div className="muted" style={{ padding: 12 }}>Kein Kunde gefunden.</div>}
|
})}
|
||||||
|
{filteredCustomers.length === 0 && <p className="muted pick-empty">Kein Kunde gefunden.</p>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.step === 'ticket' && (
|
{state.step === 'ticket' && (
|
||||||
<>
|
<>
|
||||||
{ticketsLoading ? <FaSpinner className="spinner" /> : ticketsError ? (
|
{ticketsLoading ? <p className="muted">Wird geladen …</p> : ticketsError ? (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<>
|
||||||
<div className="text-red" style={{ marginBottom: 8 }}>Die Tickets des Kunden konnten nicht geladen werden.</div>
|
<p className="msg err" role="alert">Die Tickets des Kunden konnten nicht geladen werden.</p>
|
||||||
<Button onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
|
<div className="row-buttons">
|
||||||
|
<Button type="button" onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="list" style={{ marginBottom: 12 }}>
|
<div className="pick-list">
|
||||||
{openTickets.map((t) => (
|
{openTickets.map((t) => {
|
||||||
<div key={t.$id} className="list-row" style={{ cursor: 'pointer', background: state.ticketId === t.$id ? 'var(--surface-2)' : undefined }}
|
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 })}>
|
onClick={() => patch({ ticketId: t.$id, newTicket: null })}>
|
||||||
<div className="grow">
|
<span className="list-row-text">
|
||||||
<div style={{ fontWeight: 700 }}>#{t.woid} {t.topic}</div>
|
<span className="cell-title">#{t.woid} {t.topic}</span>
|
||||||
<div className="muted">{[t.type, t.status].filter(Boolean).join(' · ')}</div>
|
<span className="cell-sub">{[t.type, t.status].filter(Boolean).join(' · ')}</span>
|
||||||
</div>
|
</span>
|
||||||
{state.ticketId === t.$id && <FaCheck />}
|
{picked && <Badge>Ausgewählt</Badge>}
|
||||||
</div>
|
</button>
|
||||||
))}
|
)
|
||||||
{openTickets.length === 0 && <div className="muted" style={{ padding: 12 }}>Dieser Kunde hat keine offenen Tickets.</div>}
|
})}
|
||||||
|
{openTickets.length === 0 && <p className="muted pick-empty">Dieser Kunde hat keine offenen Tickets.</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!ticketsError && !ticketsLoading && (
|
{!ticketsError && !ticketsLoading && (
|
||||||
<Button variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}><FaPlus /> Neues Ticket anlegen</Button>
|
<div className="row-buttons">
|
||||||
|
<Button type="button" variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}>Neues Ticket anlegen</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{state.newTicket && (
|
{state.newTicket && (
|
||||||
<div style={{ marginTop: 12 }}>
|
<>
|
||||||
<div className="flex gap-3 wrap">
|
<div className="field-row-3">
|
||||||
<Field label="Thema"><input className="form-control" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
|
<Field label="Thema" htmlFor="assign-topic"><input id="assign-topic" className="input" 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="Angefragt von" htmlFor="assign-requested-by"><input id="assign-requested-by" className="input" 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>
|
<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"><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>
|
</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' && (
|
{state.step === 'summary' && (
|
||||||
<>
|
<>
|
||||||
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
|
<ul className="summary">
|
||||||
<li>Art: <strong>{kindObj?.label}</strong></li>
|
<li>Art: <strong>{kindObj?.label}</strong></li>
|
||||||
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
|
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
|
||||||
{state.forCustomer && (
|
{state.forCustomer && (
|
||||||
@@ -357,41 +372,44 @@ export default function AssignProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
{isWebsiteWithCustomer && hasPreviewAlready && (
|
{isWebsiteWithCustomer && hasPreviewAlready && (
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted">
|
||||||
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
|
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
|
||||||
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
|
? <>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".</>}
|
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isWebsiteWithCustomer && !hasPreviewAlready && (
|
{isWebsiteWithCustomer && !hasPreviewAlready && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<>
|
||||||
<label className="flex gap-2" style={{ alignItems: 'center' }}>
|
<label className="check">
|
||||||
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
||||||
Preview einschalten
|
Preview einschalten
|
||||||
</label>
|
</label>
|
||||||
{previewOn && (
|
{previewOn && (
|
||||||
<Field label="Subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
|
<Field label="Subdomain" htmlFor="assign-subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
|
||||||
<input className="form-control" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
|
<input id="assign-subdomain" className="input" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
<div className="row-buttons">
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
<Button type="button" variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
||||||
|
{busy ? 'Speichert …' : 'Abschließen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="text-red" style={{ marginTop: 10 }}>{error}</div>}
|
{error && <p className="msg err" role="alert">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-2" style={{ marginTop: 16 }}>
|
<div className="row-buttons">
|
||||||
{state.step !== 'kind' && (
|
{state.step !== 'kind' && (
|
||||||
<Button variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}><FaArrowLeft /> Zurück</Button>
|
<Button type="button" variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}>Zurück</Button>
|
||||||
)}
|
)}
|
||||||
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
|
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
|
||||||
<Button variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter <FaArrowRight /></Button>
|
<Button type="button" variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -403,5 +421,5 @@ function TicketLink({ ticketId }) {
|
|||||||
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
||||||
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
||||||
}, [ticketId])
|
}, [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 { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
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 { completeBankAuth } from '../lib/financeApi'
|
||||||
import { dateDe } from '../lib/financeFormat'
|
import { dateDe } from '../lib/financeFormat'
|
||||||
import { parseCallback, successResult, failureResult, isResult } from '../lib/bankCallback'
|
import { parseCallback, successResult, failureResult, isResult } from '../lib/bankCallback'
|
||||||
@@ -38,28 +38,25 @@ export default function BankCallbackPage() {
|
|||||||
if (result) {
|
if (result) {
|
||||||
body = (
|
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
|
{result.ok
|
||||||
? `Bankzugang erneuert, gültig bis ${dateDe(result.validUntil)}`
|
? `Bankzugang erneuert, gültig bis ${dateDe(result.validUntil)}`
|
||||||
: result.message}
|
: result.message}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div className="fin-form-actions">
|
||||||
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
|
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else if (params) {
|
} else if (params) {
|
||||||
body = (
|
body = (
|
||||||
<div className="text-center" role="status">
|
<div className="loading" role="status">Bankzugang wird eingerichtet …</div>
|
||||||
<div className="spinner" />
|
|
||||||
<p className="muted">Bankzugang wird eingerichtet …</p>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
<p className="muted">Zu dieser Seite liegt keine Rückmeldung der Bank vor.</p>
|
<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>
|
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -69,7 +66,7 @@ export default function BankCallbackPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Bankzugang" />
|
<PageHeader title="Bankzugang" />
|
||||||
<Card>{body}</Card>
|
{body}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ export default function CreditsPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Gutschriften" subtitle="Übernahmegutschriften: bereits beim alten Anbieter bezahlte Leistungen verrechnen" />
|
<PageHeader title="Gutschriften" subtitle="Übernahmegutschriften: bereits beim alten Anbieter bezahlte Leistungen verrechnen" />
|
||||||
<CreditsView />
|
<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.
|
Ü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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { FaArrowLeft, FaRotateRight } from 'react-icons/fa6'
|
|
||||||
import { PageHeader, Card, Button, EmptyState, AutocompleteInput } from '../components/ui'
|
import { PageHeader, Card, Button, EmptyState, AutocompleteInput } from '../components/ui'
|
||||||
import ChatThread from '../components/chat/ChatThread'
|
import ChatThread from '../components/chat/ChatThread'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
@@ -64,11 +63,10 @@ export default function CustomerChatPage() {
|
|||||||
<div className={`chat-page ${kunde ? 'has-thread' : ''}`}>
|
<div className={`chat-page ${kunde ? 'has-thread' : ''}`}>
|
||||||
<div className="chat-side">
|
<div className="chat-side">
|
||||||
<AutocompleteInput value={query} onChange={onPick} getSuggestions={suggestions} placeholder="Neue Nachricht an Kunde…" />
|
<AutocompleteInput value={query} onChange={onPick} getSuggestions={suggestions} placeholder="Neue Nachricht an Kunde…" />
|
||||||
<Card pad={false}>
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 wrap" style={{ padding: 12 }}>
|
<div className="flex items-center gap-2 wrap">
|
||||||
<span className="muted">{error}</span>
|
<span className="msg err" role="alert">{error}</span>
|
||||||
<Button variant="ghost" onClick={() => setReload((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
<Button onClick={() => setReload((k) => k + 1)}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
|
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
|
||||||
@@ -77,30 +75,28 @@ export default function CustomerChatPage() {
|
|||||||
<button
|
<button
|
||||||
key={c.customerId}
|
key={c.customerId}
|
||||||
type="button"
|
type="button"
|
||||||
className={`chat-list-item ${c.customerId === kunde ? 'active' : ''}`}
|
className="chat-list-item"
|
||||||
|
aria-current={c.customerId === kunde ? 'true' : undefined}
|
||||||
onClick={() => open(c.customerId)}
|
onClick={() => open(c.customerId)}
|
||||||
>
|
>
|
||||||
<span className="chat-list-top">
|
<span className="chat-list-top">
|
||||||
<strong>{c.customerName}</strong>
|
<strong>{c.customerName}</strong>
|
||||||
{c.unreadFromCustomer > 0 && <span className="side-count">{c.unreadFromCustomer}</span>}
|
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="chat-list-last">{lastLine(c)}</span>
|
<span className="chat-list-last">{lastLine(c)}</span>
|
||||||
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
|
{c.unreadFromCustomer > 0 && <span className="chat-unread">{c.unreadFromCustomer}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-main">
|
<div className="chat-main">
|
||||||
{kunde ? (
|
{kunde ? (
|
||||||
<Card title={
|
<>
|
||||||
<span className="flex items-center gap-2">
|
<Button className="chat-back" onClick={() => setParams({})}>← Alle Unterhaltungen</Button>
|
||||||
<Button variant="ghost" size="sm" className="chat-back" onClick={() => setParams({})}><FaArrowLeft /> Alle Unterhaltungen</Button>
|
<Card title={nameOf(kunde)}>
|
||||||
{nameOf(kunde)}
|
|
||||||
</span>
|
|
||||||
}>
|
|
||||||
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
|
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card><EmptyState title="Unterhaltung links auswählen" hint="Oder oben eine neue Nachricht an einen Kunden beginnen." /></Card>
|
<Card><EmptyState title="Unterhaltung links auswählen" hint="Oder oben eine neue Nachricht an einen Kunden beginnen." /></Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+143
-102
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
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 { 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 { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||||
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
||||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||||
@@ -11,7 +10,11 @@ import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '
|
|||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
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 CustomerViewLog from '../components/customer/CustomerViewLog'
|
||||||
import ChatThread from '../components/chat/ChatThread'
|
import ChatThread from '../components/chat/ChatThread'
|
||||||
import AboStatusCard from '../components/customer/AboStatusCard'
|
import AboStatusCard from '../components/customer/AboStatusCard'
|
||||||
@@ -24,6 +27,12 @@ import { formatLastLogin } from '../lib/lastLogin'
|
|||||||
|
|
||||||
const PORTAL_URL = 'https://project.webklar.com'
|
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() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const [customer, setCustomer] = useState(null)
|
const [customer, setCustomer] = useState(null)
|
||||||
@@ -53,13 +62,14 @@ export default function CustomerDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
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 (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 (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)
|
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
|
// „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 activeTab = resolveCustomerTab(tab, isAdmin)
|
||||||
const archived = isArchived(customer)
|
const archived = isArchived(customer)
|
||||||
|
const title = customerTitle(customer)
|
||||||
|
|
||||||
const toggleArchived = async () => {
|
const toggleArchived = async () => {
|
||||||
await setCustomerArchived(customer, !archived)
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
crumb={[{ label: 'Kunden', to: '/customers' }, { label: title }]}
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
eyebrow={customerEyebrow(customer)}
|
||||||
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
|
title={title}
|
||||||
{customer.name}
|
subtitle={subtitle}
|
||||||
{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(' · ')}
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
{isAdmin && <Button variant="danger" onClick={removeCustomer}>Kunde löschen</Button>}
|
||||||
|
<Button onClick={toggleArchived}>{archived ? 'Wiederherstellen' : 'Archivieren'}</Button>
|
||||||
<KundenansichtButton customer={customer} />
|
<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' && (
|
{activeTab === 'abo' && (
|
||||||
<>
|
<>
|
||||||
<AboStatusCard customerId={customer.$id} />
|
<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} />}
|
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
|
||||||
@@ -141,7 +153,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
importantCustomer: !!customer.importantCustomer,
|
importantCustomer: !!customer.importantCustomer,
|
||||||
})
|
})
|
||||||
const [busy, setBusy] = useState(false)
|
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 })
|
const ready = isInvoiceReady({ ...customer, ...form })
|
||||||
|
|
||||||
@@ -149,72 +161,84 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||||
setMsg('Gespeichert')
|
setMsg({ tone: 'ok', text: 'Gespeichert' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = async () => {
|
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)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await upgradeToCustomer(customer, form)
|
await upgradeToCustomer(customer, form)
|
||||||
setMsg('Zum festen Kunden gemacht.')
|
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
<div className="two-col">
|
||||||
|
<div>
|
||||||
<Card title="Stammdaten">
|
<Card title="Stammdaten">
|
||||||
<div className="flex gap-3 wrap">
|
<dl className="kv kunde-kv">
|
||||||
<Field label="Unternehmen / Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-name">Unternehmen / Name</label></dt>
|
||||||
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
|
<dd><input id="kunde-name" className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></dd>
|
||||||
</div>
|
<dt><label htmlFor="kunde-code">Code</label></dt>
|
||||||
<div className="flex gap-3 wrap">
|
<dd><input id="kunde-code" className="input" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></dd>
|
||||||
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-email">E-Mail</label></dt>
|
||||||
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
|
<dd><input id="kunde-email" className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></dd>
|
||||||
</div>
|
<dt><label htmlFor="kunde-phone">Telefon</label></dt>
|
||||||
<Field label="Standort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
|
<dd><input id="kunde-phone" className="input" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></dd>
|
||||||
<Field label="Ansprechpartner"><input className="form-control" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-location">Standort</label></dt>
|
||||||
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
|
<dd><input id="kunde-location" className="input" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></dd>
|
||||||
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
|
<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 })} />
|
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
||||||
Wichtiger Kunde
|
Wichtiger Kunde
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 items-center wrap">
|
<div className="toolbar">
|
||||||
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
|
<Button variant="primary" onClick={save} disabled={busy}>{busy ? 'Speichert …' : 'Speichern'}</Button>
|
||||||
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}><FaArrowUp /> Zum festen Kunden machen</Button>}
|
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}>Zum festen Kunden machen</Button>}
|
||||||
{msg && <span className="muted">{msg}</span>}
|
<Msg msg={msg} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Rechnungsdaten">
|
<Card title="Rechnungsdaten">
|
||||||
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Adresse unvollständig</div>}
|
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
|
||||||
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
|
<dl className="kv kunde-kv">
|
||||||
<div className="flex gap-3 wrap">
|
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
|
||||||
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
|
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
|
||||||
<Field label="Stadt"><input className="form-control" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
|
||||||
</div>
|
<dd><input id="kunde-postal" className="input" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></dd>
|
||||||
<div className="flex gap-3 wrap">
|
<dt><label htmlFor="kunde-city">Stadt</label></dt>
|
||||||
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
|
<dd><input id="kunde-city" className="input" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></dd>
|
||||||
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-country">Land</label></dt>
|
||||||
</div>
|
<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>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PortalAccessCard({ customer, onSaved }) {
|
function PortalAccessCard({ customer, onSaved }) {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
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 hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||||||
const currentPassword = getCustomerPortalPassword(customer)
|
const currentPassword = getCustomerPortalPassword(customer)
|
||||||
@@ -230,21 +254,21 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
|
|
||||||
const savePassword = async () => {
|
const savePassword = async () => {
|
||||||
if (!customer.email) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (!password || password.length < 8) {
|
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
|
return
|
||||||
}
|
}
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await updateCustomerWithPortalAccess(customer.$id, { password })
|
await updateCustomerWithPortalAccess(customer.$id, { password })
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setMsg(hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.')
|
setMsg({ tone: 'ok', text: hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -252,44 +276,41 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Portal-Zugang">
|
<Card title="Portal-Zugang">
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
<dl className="kv kunde-kv">
|
||||||
<span className="muted">Kundenportal</span>
|
<dt>Kundenportal</dt>
|
||||||
|
<dd>
|
||||||
{hasAccess
|
{hasAccess
|
||||||
? <Badge tone="ok" dot>freigeschaltet</Badge>
|
? <Badge tone="ok">freigeschaltet</Badge>
|
||||||
: <Badge tone="muted">kein Zugang</Badge>}
|
: <Badge tone="muted">kein Zugang</Badge>}
|
||||||
</div>
|
</dd>
|
||||||
|
<dt>Letzter Login</dt>
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
<dd>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</dd>
|
||||||
<span className="muted">Letzter Login</span>
|
<dt>Login (E-Mail)</dt>
|
||||||
<span>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</span>
|
<dd><CopyableCredential value={customer.email} label="Login" /></dd>
|
||||||
</div>
|
<dt>Passwort</dt>
|
||||||
|
<dd><CopyableCredential value={currentPassword} secret label="Passwort" /></dd>
|
||||||
<Field label="Login (E-Mail)">
|
<dt><label htmlFor="kunde-neues-passwort">{hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}</label></dt>
|
||||||
<CopyableCredential value={customer.email} label="Login" />
|
<dd>
|
||||||
</Field>
|
|
||||||
<Field label="Passwort">
|
|
||||||
<CopyableCredential value={currentPassword} secret label="Passwort" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}>
|
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
id="kunde-neues-passwort"
|
||||||
|
className="input"
|
||||||
type="text"
|
type="text"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="min. 8 Zeichen"
|
placeholder="min. 8 Zeichen"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center wrap">
|
<div className="toolbar">
|
||||||
<Button variant="primary" onClick={savePassword} disabled={busy}>
|
<Button onClick={savePassword} disabled={busy}>
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
|
{busy ? 'Speichert …' : hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}
|
||||||
</Button>
|
</Button>
|
||||||
<KundenansichtButton customer={customer} variant="ghost" />
|
<KundenansichtButton customer={customer} />
|
||||||
{msg && <span className="muted">{msg}</span>}
|
<Msg msg={msg} />
|
||||||
</div>
|
</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
|
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
|
||||||
und sieht dort seine Projekte/Previews.
|
und sieht dort seine Projekte/Previews.
|
||||||
</p>
|
</p>
|
||||||
@@ -298,6 +319,7 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CustomerTickets({ customerId }) {
|
function CustomerTickets({ customerId }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [tickets, setTickets] = useState([])
|
const [tickets, setTickets] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -321,25 +343,44 @@ function CustomerTickets({ customerId }) {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [customerId])
|
}, [customerId])
|
||||||
|
|
||||||
if (loading) return <Card><FaSpinner className="spinner" /></Card>
|
if (loading) return <div className="loading">Wird geladen …</div>
|
||||||
if (tickets.length === 0) return <Card><EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." /></Card>
|
if (tickets.length === 0) return <EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card pad={false}>
|
<table className="table">
|
||||||
<div className="list">
|
<thead>
|
||||||
{tickets.map((t) => (
|
<tr>
|
||||||
<div key={t.$id} className="list-row">
|
<th>Ticket</th>
|
||||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
|
<th>Status</th>
|
||||||
<div className="grow">
|
</tr>
|
||||||
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
</thead>
|
||||||
<div className="muted">{t.type}</div>
|
<tbody>
|
||||||
</div>
|
{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} />
|
<UrgentPill priority={t.priority} />
|
||||||
<StatusPill status={t.status} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
{t.type && <div className="cell-sub">{t.type}</div>}
|
||||||
</div>
|
</td>
|
||||||
</Card>
|
<td><StatusPill status={t.status} /></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +400,7 @@ function CustomerProjects({ customerId }) {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [customerId, fetchAllProjects])
|
}, [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>
|
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+138
-105
@@ -1,37 +1,74 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { FaSpinner, FaPlus, FaStar } from 'react-icons/fa6'
|
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { createLead, customerStage, isArchived } from '../lib/leads'
|
import { createLead } from '../lib/leads'
|
||||||
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
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() {
|
export default function CustomersPage() {
|
||||||
const { customers, loading, refresh, createCustomer } = useCustomers()
|
const { customers, loading, refresh, createCustomer } = useCustomers()
|
||||||
const { user, isAdmin } = useAuth()
|
const { user, isAdmin } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
// 105 von 110 Eintraegen sind Leads der Lead-Automatik, deshalb startet die Liste bei den festen Kunden
|
// 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 [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => filterCustomers(customers, { stage, search }), [customers, stage, search])
|
||||||
return (customers || [])
|
// Zähler mit derselben Funktion wie die Liste (beachten den Suchtext); bis zum Laden ohne Zahl
|
||||||
.filter((c) => {
|
const counts = useMemo(() => stageCounts(customers, search), [customers, search])
|
||||||
const archived = isArchived(c)
|
const tabs = CUSTOMER_TABS.map((tab) => ({ id: tab.id, label: tab.label, count: loading ? undefined : counts[tab.id] }))
|
||||||
// Archivierte nur im Archiviert-Tab, sonst nie
|
|
||||||
if (stage === 'archived') return archived
|
let content
|
||||||
if (archived) return false
|
if (loading) {
|
||||||
const st = customerStage(c)
|
content = <div className="loading">Wird geladen …</div>
|
||||||
if (st === 'lost') return stage === 'all'
|
} else if (filtered.length === 0) {
|
||||||
if (stage === 'all') return true
|
content = <EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
|
||||||
return st === stage
|
} else {
|
||||||
})
|
content = (
|
||||||
.filter((c) => {
|
<table className="table">
|
||||||
if (!search) return true
|
<thead>
|
||||||
const s = search.toLowerCase()
|
<tr>
|
||||||
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
|
<th>Unternehmen</th>
|
||||||
})
|
<th className="hide-sm num">Kundennummer</th>
|
||||||
}, [customers, search, stage])
|
<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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -41,15 +78,29 @@ export default function CustomersPage() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{isAdmin && (
|
{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' && (
|
{showCreate === 'lead' && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CreateLeadForm
|
<CreateLeadForm
|
||||||
onCancel={() => setShowCreate(null)}
|
onCancel={() => setShowCreate(null)}
|
||||||
onDone={async (data) => {
|
onDone={async (data) => {
|
||||||
@@ -59,11 +110,9 @@ export default function CustomersPage() {
|
|||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate === 'customer' && isAdmin && (
|
{showCreate === 'customer' && isAdmin && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CreateCustomerForm
|
<CreateCustomerForm
|
||||||
onCancel={() => setShowCreate(null)}
|
onCancel={() => setShowCreate(null)}
|
||||||
onCreate={async (data) => {
|
onCreate={async (data) => {
|
||||||
@@ -76,54 +125,7 @@ export default function CustomersPage() {
|
|||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,22 +149,33 @@ function CreateLeadForm({ onCancel, onDone }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Neuer Lead (potenzieller Kunde)">
|
<Dialog
|
||||||
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
|
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.
|
Fuer einen Lead reichen Unternehmen und E-Mail. Es wird automatisch ein Akquise-Ticket erstellt.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={submit}>
|
<div className="field-row">
|
||||||
<div className="flex gap-3 wrap">
|
<Field label="Unternehmen" htmlFor="lead-company">
|
||||||
<Field label="Unternehmen"><input className="form-control" value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
|
<input id="lead-company" className="input" value={company} onChange={(e) => setCompany(e.target.value)} required />
|
||||||
<Field label="E-Mail"><input className="form-control" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
|
</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>
|
</div>
|
||||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
</Dialog>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,24 +198,44 @@ export function CreateCustomerForm({ onCancel, onCreate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Neuer Kunde mit Portal-Zugang">
|
<Dialog
|
||||||
<form onSubmit={submit}>
|
as="form"
|
||||||
<div className="flex gap-3 wrap">
|
wide
|
||||||
<Field label="Unternehmen / Name"><input className="form-control" name="name" value={form.name} onChange={set('name')} required /></Field>
|
title="Neuer Kunde mit Portal-Zugang"
|
||||||
<Field label="E-Mail (Portal-Login)"><input className="form-control" type="email" name="email" value={form.email} onChange={set('email')} required /></Field>
|
busy={busy}
|
||||||
<Field label="Kundennummer"><input className="form-control" name="code" value={form.code} onChange={set('code')} /></Field>
|
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>
|
||||||
<div className="flex gap-3 wrap">
|
<div className="field-row-3">
|
||||||
<Field label="Telefon"><input className="form-control" name="phone" value={form.phone} onChange={set('phone')} /></Field>
|
<Field label="Telefon" htmlFor="customer-phone">
|
||||||
<Field label="Standort"><input className="form-control" name="location" value={form.location} onChange={set('location')} /></Field>
|
<input id="customer-phone" className="input" name="phone" value={form.phone} onChange={set('phone')} />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
</Dialog>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+14
-15
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { FaGear, FaSpinner } from 'react-icons/fa6'
|
import { PageHeader, Button } from '../components/ui'
|
||||||
import { PageHeader, Card, Button } from '../components/ui'
|
|
||||||
import StatusLine from '../components/finance/StatusLine'
|
import StatusLine from '../components/finance/StatusLine'
|
||||||
import LiquidityHead from '../components/finance/LiquidityHead'
|
import LiquidityHead from '../components/finance/LiquidityHead'
|
||||||
import TodoList from '../components/finance/TodoList'
|
import TodoList from '../components/finance/TodoList'
|
||||||
@@ -16,6 +15,7 @@ import { useFinanceTransactions } from '../hooks/useFinanceTransactions'
|
|||||||
import { useOpenInvoices } from '../hooks/useOpenInvoices'
|
import { useOpenInvoices } from '../hooks/useOpenInvoices'
|
||||||
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
||||||
import { planTodoAction, safeUrl } from '../lib/financeView'
|
import { planTodoAction, safeUrl } from '../lib/financeView'
|
||||||
|
import { noticeProps } from '../lib/financeUi'
|
||||||
|
|
||||||
export default function FinancePage() {
|
export default function FinancePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -139,29 +139,30 @@ export default function FinancePage() {
|
|||||||
let body
|
let body
|
||||||
if (!data && loading) {
|
if (!data && loading) {
|
||||||
body = (
|
body = (
|
||||||
<div className="empty" role="status">
|
<div className="loading" role="status">
|
||||||
<FaSpinner className="spinner" aria-hidden="true" />
|
Finanzen werden geladen …
|
||||||
<p className="muted">Finanzen werden geladen …</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (!data) {
|
} else if (!data) {
|
||||||
body = (
|
body = (
|
||||||
<Card>
|
<div className="fin-load-error">
|
||||||
<p role="alert" style={{ fontWeight: 600, marginTop: 0 }}>{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
|
<p className="msg err" role="alert">{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
|
||||||
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
|
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<div className="fin-stack">
|
<div className="fin-stack">
|
||||||
{error && (
|
{error && (
|
||||||
<p className="fin-hint fin-hint-warn" role="alert">
|
<p {...noticeProps('warn')}>
|
||||||
Die Übersicht konnte nicht neu geladen werden: {error}{' '}
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="fin-top">
|
||||||
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
||||||
<LiquidityHead kopf={data.kopf} />
|
<LiquidityHead kopf={data.kopf} />
|
||||||
|
</div>
|
||||||
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
|
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
|
||||||
<div className="fin-cols">
|
<div className="fin-cols">
|
||||||
<ForecastTable
|
<ForecastTable
|
||||||
@@ -195,15 +196,13 @@ export default function FinancePage() {
|
|||||||
title="Finanzen"
|
title="Finanzen"
|
||||||
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
|
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
|
||||||
actions={
|
actions={
|
||||||
<Button as={Link} variant="ghost" to="/finance/einstellungen">
|
<Button as={Link} to="/finance/einstellungen">Einstellungen</Button>
|
||||||
<FaGear aria-hidden="true" /> Einstellungen
|
|
||||||
</Button>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{assignNotice && (
|
{assignNotice && (
|
||||||
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
|
<p {...noticeProps('warn', 'fin-page-notice')}>
|
||||||
{assignNotice}{' '}
|
{assignNotice}{' '}
|
||||||
<Button size="sm" variant="ghost" onClick={() => setAssignNotice('')}>Schließen</Button>
|
<button type="button" className="link" onClick={() => setAssignNotice('')}>Schließen</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{body}
|
{body}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FaArrowLeft } from 'react-icons/fa6'
|
|
||||||
import { PageHeader, Button } from '../components/ui'
|
import { PageHeader, Button } from '../components/ui'
|
||||||
import SettingsCard from '../components/finance/SettingsCard'
|
import SettingsCard from '../components/finance/SettingsCard'
|
||||||
import BankAccessCard from '../components/finance/BankAccessCard'
|
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
|
* 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() {
|
export default function FinanceSettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -16,8 +15,8 @@ export default function FinanceSettingsPage() {
|
|||||||
title="Finanzen: Einstellungen"
|
title="Finanzen: Einstellungen"
|
||||||
subtitle="Rücklagen, Bankzugang und Fixkosten"
|
subtitle="Rücklagen, Bankzugang und Fixkosten"
|
||||||
actions={
|
actions={
|
||||||
<Button as={Link} variant="ghost" to="/finance">
|
<Button as={Link} to="/finance">
|
||||||
<FaArrowLeft aria-hidden="true" /> Zurück zu den Finanzen
|
← Zurück zu den Finanzen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function LegalPage() {
|
|||||||
const active = tab === 'texte' ? texts : contracts
|
const active = tab === 'texte' ? texts : contracts
|
||||||
|
|
||||||
let body
|
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) {
|
else if (active.missingSetup) {
|
||||||
body = (
|
body = (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -31,7 +31,7 @@ export default function LegalPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Rechtliches" subtitle="Rechtstexte mit Versionsgeschichte und Verträge mit Anbietern" />
|
<PageHeader title="Rechtliches" subtitle="Rechtstexte mit Versionsgeschichte und Verträge mit Anbietern" />
|
||||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
<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}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function OffersPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" />
|
<PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" />
|
||||||
<OffersView />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+266
-403
@@ -1,18 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
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 PreviewBlock from '../components/PreviewBlock'
|
||||||
import {
|
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||||
FaFolder,
|
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||||
FaPlus,
|
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
|
||||||
FaArrowsRotate,
|
|
||||||
FaPen,
|
|
||||||
FaBoxArchive,
|
|
||||||
FaBoxOpen,
|
|
||||||
FaGlobe,
|
|
||||||
FaLock,
|
|
||||||
FaCircleInfo,
|
|
||||||
FaClone,
|
|
||||||
} from 'react-icons/fa6'
|
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useWorkorders } from '../hooks/useWorkorders'
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
@@ -27,70 +18,22 @@ import {
|
|||||||
importNewReposThrottled,
|
importNewReposThrottled,
|
||||||
} from '../lib/projectAdminApi'
|
} from '../lib/projectAdminApi'
|
||||||
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
|
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 { slugify } from '../lib/subdomain'
|
||||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
|
||||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
|
||||||
|
|
||||||
const WORKORDER_FILTERS = { limit: 500 }
|
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() {
|
export default function ProjectsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
|
const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||||
const { employees } = useEmployees()
|
const { employees } = useEmployees()
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
|
const [loadedOnce, setLoadedOnce] = useState(false)
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [customerFilter, setCustomerFilter] = 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.
|
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetchAllProjects()
|
fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) })
|
||||||
importNewReposThrottled()
|
importNewReposThrottled()
|
||||||
.then((r) => { if (r && !cancelled) fetchAllProjects() })
|
.then((r) => { if (r && !cancelled) fetchAllProjects() })
|
||||||
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
|
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [fetchAllProjects])
|
}, [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 ticketMap = useMemo(() => {
|
||||||
const map = {}
|
const map = {}
|
||||||
for (const wo of workorders) {
|
for (const wo of workorders) {
|
||||||
@@ -355,199 +290,57 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_OPTIONS = [
|
// Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||||
{ value: 'all', label: `Aktive Projekte (${counts.all})` },
|
const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||||||
{ 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-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
let content
|
||||||
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
if (!loadedOnce) {
|
||||||
|
// Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen
|
||||||
return (
|
content = <div className="loading">Wird geladen …</div>
|
||||||
<div className="page">
|
} else if (filteredProjects.length === 0) {
|
||||||
<header className="text-center mb-2">
|
content = <EmptyState title={filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'} />
|
||||||
<h2>Website-Projekte</h2>
|
} else {
|
||||||
</header>
|
content = (
|
||||||
|
<table className="table projects-table" role="table">
|
||||||
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
|
<thead role="rowgroup">
|
||||||
<input
|
<tr role="row">
|
||||||
type="search"
|
<th role="columnheader">Projekt</th>
|
||||||
className="form-control"
|
<th role="columnheader">Kunde</th>
|
||||||
placeholder="Nach Titel suchen…"
|
<th role="columnheader" className="hide-md">Art</th>
|
||||||
value={search}
|
<th role="columnheader" className="hide-md">Adresse</th>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<th role="columnheader" className="hide-sm num">Ticket</th>
|
||||||
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
|
<th role="columnheader">Status</th>
|
||||||
/>
|
<th role="columnheader" className="hide-md right">Zuletzt geändert</th>
|
||||||
<select
|
<th role="columnheader" className="right">Aktionen</th>
|
||||||
className="form-control"
|
</tr>
|
||||||
style={{ width: 'auto', minWidth: '170px' }}
|
</thead>
|
||||||
value={filter}
|
<tbody role="rowgroup">
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredProjects.map((project) => {
|
{filteredProjects.map((project) => {
|
||||||
const type = typeOf(project)
|
const actions = projectActions(project)
|
||||||
const t = TYPES[type]
|
const archived = Boolean(project.archived)
|
||||||
const hosted = HOSTED_TYPES.has(type)
|
const type = PROJECT_TYPES[actions.type]
|
||||||
const ready = isProjectReady(project)
|
const name = project.projectName || project.giteaRepoName || '(ohne Name)'
|
||||||
const needsHosting = hosted && !ready && !project.archived
|
const ticketRef = projectTicketRef(project, ticketMap)
|
||||||
|
const updated = updatedLabel(project.$updatedAt)
|
||||||
return (
|
return (
|
||||||
<div
|
<tr
|
||||||
key={project.$id}
|
key={project.$id}
|
||||||
className="card"
|
role="row"
|
||||||
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
|
className={`row${archived ? ' is-archived' : ''}`}
|
||||||
|
onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
<td role="cell">
|
||||||
<div style={{ minWidth: 0 }}>
|
<div className="cell-title">
|
||||||
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
|
<button type="button" className="link" onClick={() => openEdit(project)}>{name}</button>
|
||||||
{project.projectName || project.giteaRepoName || '(ohne Name)'}
|
|
||||||
</div>
|
</div>
|
||||||
{project.repoFullName && (
|
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
|
||||||
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
|
</td>
|
||||||
{project.repoFullName}
|
<td role="cell" data-label="Kunde">
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="select"
|
||||||
style={{ fontSize: '13px' }}
|
aria-label={`Kunde von ${name}`}
|
||||||
value={project.customerId || ''}
|
value={project.customerId || ''}
|
||||||
disabled={assignBusyId === project.$id || project.archived}
|
disabled={assignBusyId === project.$id || archived}
|
||||||
onChange={(e) => handleAssignCustomer(project, e.target.value)}
|
onChange={(e) => handleAssignCustomer(project, e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Kein Kunde</option>
|
<option value="">Kein Kunde</option>
|
||||||
@@ -557,109 +350,196 @@ export default function ProjectsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</td>
|
||||||
|
<td role="cell" className="hide-md">
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-dark"
|
className="link"
|
||||||
style={{ padding: '8px 12px' }}
|
title="Projekt jetzt deployen/hosten"
|
||||||
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)}
|
onClick={() => handleHostNow(project)}
|
||||||
disabled={hostBusyId === project.$id}
|
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} />
|
{hostBusyId === project.$id ? 'Wird geladen …' : 'Hosten'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
|
||||||
)}
|
)}
|
||||||
{hosted && ready && !project.archived && (
|
{actions.preview && <PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />}
|
||||||
|
{actions.refresh && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-dark"
|
className="link"
|
||||||
style={{ padding: '8px 12px' }}
|
title="Aktualisieren – Website neu deployen"
|
||||||
onClick={() => handleRefresh(project)}
|
onClick={() => handleRefresh(project)}
|
||||||
disabled={refreshBusyId === project.$id}
|
disabled={refreshBusyId === project.$id}
|
||||||
title="Aktualisieren – Website neu deployen"
|
|
||||||
aria-label="Aktualisieren – Website neu deployen"
|
|
||||||
>
|
>
|
||||||
<FaArrowsRotate
|
{refreshBusyId === project.$id ? 'Wird geladen …' : 'Aktualisieren'}
|
||||||
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<GiteaLinkButton href={project.giteaRepoUrl} />
|
{actions.gitea && <GiteaLinkButton href={project.giteaRepoUrl} />}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</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>
|
</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 --- */}
|
{/* --- Neues Projekt --- */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
|
as="form"
|
||||||
<div className="overlay-content">
|
onSubmit={handleCreate}
|
||||||
<h2 className="mb-2">Neues Website-Projekt</h2>
|
title="Neues Website-Projekt"
|
||||||
{createError && (
|
busy={createLoading}
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
onClose={() => setShowCreateModal(false)}
|
||||||
{createError}
|
footer={
|
||||||
</div>
|
<>
|
||||||
)}
|
<span className="grow" />
|
||||||
<form onSubmit={handleCreate}>
|
<Button type="submit" variant="primary" disabled={createLoading}>
|
||||||
<div className="form-group">
|
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
|
||||||
<label className="form-label">Anzeigename</label>
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createError && <p className="msg err" role="alert">{createError}</p>}
|
||||||
|
<Field label="Anzeigename" htmlFor="projekt-neu-name">
|
||||||
<input
|
<input
|
||||||
|
id="projekt-neu-name"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
value={createForm.displayName}
|
value={createForm.displayName}
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="form-group">
|
<Field
|
||||||
<label className="form-label">Subdomain (optional)</label>
|
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
|
<input
|
||||||
|
id="projekt-neu-subdomain"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
value={createForm.subdomain}
|
value={createForm.subdomain}
|
||||||
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
|
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
|
||||||
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
|
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
|
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||||
placeholder="leer lassen = Projekt ohne Website"
|
placeholder="leer lassen = Projekt ohne Website"
|
||||||
/>
|
/>
|
||||||
<small className="text-grey">
|
</Field>
|
||||||
Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt.
|
<Field label="Repo-Name (optional)" htmlFor="projekt-neu-repo">
|
||||||
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
|
<input
|
||||||
|
id="projekt-neu-repo"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
value={createForm.repoName}
|
value={createForm.repoName}
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
|
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
|
||||||
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
|
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="form-group">
|
<Field label="Kunde (optional)" htmlFor="projekt-neu-kunde">
|
||||||
<label className="form-label">Kunde (optional)</label>
|
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
id="projekt-neu-kunde"
|
||||||
|
className="select"
|
||||||
value={createForm.customerId}
|
value={createForm.customerId}
|
||||||
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
|
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
|
||||||
>
|
>
|
||||||
@@ -670,47 +550,69 @@ export default function ProjectsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="text-center mt-2">
|
</Dialog>
|
||||||
<button type="submit" className="btn btn-dark" disabled={createLoading}>
|
|
||||||
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* --- Projekt bearbeiten --- */}
|
{/* --- Projekt bearbeiten --- */}
|
||||||
{editProject && (
|
{editProject && (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span>
|
wide
|
||||||
<div className="overlay-content">
|
title="Projekt bearbeiten"
|
||||||
<h2 className="mb-2">Projekt bearbeiten</h2>
|
busy={editLoading || archiveBusy}
|
||||||
<p className="text-grey mb-2" style={{ fontSize: '13px' }}>
|
onClose={() => setEditProject(null)}
|
||||||
{editProject.repoFullName}
|
footer={
|
||||||
</p>
|
<>
|
||||||
{editError && (
|
{editProject.archived ? (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<button
|
||||||
{editError}
|
type="button"
|
||||||
</div>
|
className="link"
|
||||||
|
onClick={() => handleArchiveToggle(editProject)}
|
||||||
|
disabled={archiveBusy || editLoading}
|
||||||
|
>
|
||||||
|
Reaktivieren
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleArchiveToggle(editProject)}
|
||||||
|
disabled={archiveBusy || editLoading}
|
||||||
|
>
|
||||||
|
Repository archivieren
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleEditSave}>
|
<span className="grow" />
|
||||||
<div className="form-group">
|
{/* Im Fuss ausserhalb des <form>, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */}
|
||||||
<label className="form-label">Name</label>
|
<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
|
<input
|
||||||
|
id="projekt-bearbeiten-name"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
value={editForm.projectName}
|
value={editForm.projectName}
|
||||||
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
|
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
<Field label="Typ / Template" htmlFor="projekt-bearbeiten-typ" hint={PROJECT_TYPES[editForm.projectType].hint}>
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Typ / Template</label>
|
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
id="projekt-bearbeiten-typ"
|
||||||
|
className="select"
|
||||||
value={editForm.projectType}
|
value={editForm.projectType}
|
||||||
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
|
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
|
||||||
>
|
>
|
||||||
@@ -718,73 +620,34 @@ export default function ProjectsPage() {
|
|||||||
<option key={key} value={key}>{meta.label}</option>
|
<option key={key} value={key}>{meta.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<small className="text-grey">
|
</Field>
|
||||||
<FaCircleInfo /> {TYPES[editForm.projectType].hint}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editForm.projectType !== 'project' && (
|
{editForm.projectType !== 'project' && (
|
||||||
<div className="form-group">
|
<Field
|
||||||
<label className="form-label">Subdomain</label>
|
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
|
<input
|
||||||
|
id="projekt-bearbeiten-subdomain"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
value={editForm.subdomain}
|
value={editForm.subdomain}
|
||||||
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
|
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||||
placeholder="z.B. musterfirma"
|
placeholder="z.B. musterfirma"
|
||||||
/>
|
/>
|
||||||
<small className="text-grey">
|
</Field>
|
||||||
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' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={editProject.archived ? 'btn' : 'link-danger'}
|
|
||||||
onClick={() => handleArchiveToggle(editProject)}
|
|
||||||
disabled={archiveBusy || editLoading}
|
|
||||||
>
|
|
||||||
{editProject.archived
|
|
||||||
? <><FaBoxOpen /> Reaktivieren</>
|
|
||||||
: <><FaBoxArchive /> Repository archivieren</>}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-dark"
|
|
||||||
disabled={editLoading || archiveBusy || editProject.archived}
|
|
||||||
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</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" */}
|
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
|
||||||
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
|
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
|
||||||
<div className="form-group" style={{ marginTop: 16 }}>
|
<section>
|
||||||
<label className="form-label">Preview</label>
|
<h4 className="pv-title">Preview</h4>
|
||||||
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Dialog>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { useCallback, useEffect, useRef, useMemo, useState } from 'react'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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 { serverpulsApi } from '../lib/serverpulsApi'
|
||||||
import {
|
import {
|
||||||
ageLabel, formatBytes, formatNumber, formatPct, formatSince, usedPct,
|
ageLabel, deltaDir, deltaSign, formatBytes, formatNumber, formatPct, formatSince, levelTone, usedPct,
|
||||||
} from '../lib/serverpulsFormat'
|
} from '../lib/serverpulsFormat'
|
||||||
import Chart from '../components/serverpuls/Chart'
|
import Chart from '../components/serverpuls/Chart'
|
||||||
import { CHART } from '../components/serverpuls/chartColors'
|
import { CHART } from '../components/serverpuls/chartColors'
|
||||||
@@ -81,20 +82,17 @@ function Tile({ id, label, model }) {
|
|||||||
let deltaEl = null
|
let deltaEl = null
|
||||||
if (model && model.delta !== null && model.delta !== undefined) {
|
if (model && model.delta !== null && model.delta !== undefined) {
|
||||||
const d = model.delta
|
const d = model.delta
|
||||||
const dir = Math.abs(d) < 1e-9 ? 'flat' : d > 0 ? 'up' : 'down'
|
const dir = deltaDir(d)
|
||||||
const sign = dir === 'up' ? '▲ +' : dir === 'down' ? '▼ −' : ''
|
|
||||||
deltaEl = (
|
deltaEl = (
|
||||||
<div className={`sp-tile-delta sp-delta-${dir}`}>
|
<div className={`sp-tile-delta sp-delta-${dir}`}>
|
||||||
{sign}{model.fmtDelta(d)} ggü. gestern
|
{deltaSign(dir)}{model.fmtDelta(d)} ggü. gestern
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="sp-tile" data-testid={`sp-tile-${id}`}>
|
<div className="sp-tile" data-testid={`sp-tile-${id}`}>
|
||||||
<div className="sp-tile-label">{label}</div>
|
<Stat label={label} value={model ? model.main : '–'} sub={model?.sub || '\u00a0'} />
|
||||||
<div className="sp-tile-value">{model ? model.main : '–'}</div>
|
<Sparkline points={model?.spark || []} width={120} height={32} />
|
||||||
<div className="sp-tile-sub">{model?.sub || ' '}</div>
|
|
||||||
<Sparkline points={model?.spark || []} width={200} height={36} />
|
|
||||||
{deltaEl}
|
{deltaEl}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -102,40 +100,44 @@ function Tile({ id, label, model }) {
|
|||||||
|
|
||||||
function Pills({ items, value, onChange, testid }) {
|
function Pills({ items, value, onChange, testid }) {
|
||||||
return (
|
return (
|
||||||
<div className="sp-pills" data-testid={testid}>
|
<div className="seg" data-testid={testid}>
|
||||||
{items.map((it) => (
|
{items.map((it) => {
|
||||||
|
const active = value === it.id
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={it.id}
|
key={it.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`sp-pill ${value === it.id ? 'active' : ''}`}
|
className={active ? 'on' : undefined}
|
||||||
|
aria-pressed={active}
|
||||||
onClick={() => onChange(it.id)}
|
onClick={() => onChange(it.id)}
|
||||||
>
|
>
|
||||||
{it.label}
|
{it.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Cleanup({ data }) {
|
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 : []
|
const stopped = Array.isArray(data.stopped) ? data.stopped : []
|
||||||
return (
|
return (
|
||||||
<div className="sp-cleanup-body">
|
<>
|
||||||
<div className="sp-cleanup-grid">
|
<div className="stats sp-cleanup-stats">
|
||||||
<div><span className="sp-muted">Ungenutzte Images</span><strong>{formatBytes(data.imagesBytes)}</strong></div>
|
<Stat label="Ungenutzte Images" value={formatBytes(data.imagesBytes)} />
|
||||||
<div><span className="sp-muted">Build-Cache</span><strong>{formatBytes(data.buildCacheBytes)}</strong></div>
|
<Stat label="Build-Cache" value={formatBytes(data.buildCacheBytes)} />
|
||||||
<div><span className="sp-muted">Gestoppte Container</span><strong>{formatBytes(data.stoppedBytes)}</strong></div>
|
<Stat label="Gestoppte Container" value={formatBytes(data.stoppedBytes)} />
|
||||||
</div>
|
</div>
|
||||||
{stopped.length > 0 && (
|
{stopped.length > 0 && (
|
||||||
<ul className="sp-cleanup-list">
|
<ul className="sp-cleanup-list">
|
||||||
{stopped.map((s) => (
|
{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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="sp-muted sp-small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</div>
|
<p className="muted small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</p>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +247,6 @@ export default function ServerpulsPage() {
|
|||||||
const level = overview?.level || 'ok'
|
const level = overview?.level || 'ok'
|
||||||
const alerts = overview?.alerts || []
|
const alerts = overview?.alerts || []
|
||||||
const age = ageLabel(overview?.lastSample ?? null, now)
|
const age = ageLabel(overview?.lastSample ?? null, now)
|
||||||
const dot = level === 'critical' ? '🔴' : level === 'medium' ? '🟠' : '🟢'
|
|
||||||
const headline = level === 'ok'
|
const headline = level === 'ok'
|
||||||
? 'Alles ok'
|
? 'Alles ok'
|
||||||
: level === 'critical'
|
: level === 'critical'
|
||||||
@@ -256,14 +257,20 @@ export default function ServerpulsPage() {
|
|||||||
<div className="page sp-page" data-testid="sp-page">
|
<div className="page sp-page" data-testid="sp-page">
|
||||||
<PageHeader title="Serverpuls" subtitle="Zustand und Verlauf des Servers – nur Anzeige" />
|
<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">
|
<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>
|
<strong className="sp-headline">{overview ? headline : (error ? 'Keine Daten' : 'Lädt …')}</strong>
|
||||||
{overview && (
|
{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' : ''}
|
Letzte Messung {age.text}{age.stale ? ' – Daten veraltet' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -271,46 +278,46 @@ export default function ServerpulsPage() {
|
|||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<ul className="sp-alerts" data-testid="sp-alerts">
|
<ul className="sp-alerts" data-testid="sp-alerts">
|
||||||
{alerts.map((a) => (
|
{alerts.map((a) => (
|
||||||
<li key={a.id} className={`sp-alert sp-alert-${a.level}`} data-testid="sp-alert">
|
<li key={a.id} className="list-row" data-testid="sp-alert">
|
||||||
<span className="sp-alert-level">{LEVEL_LABEL[a.level] || a.level}</span>
|
<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-title">{a.title}</span>
|
||||||
<span className="sp-alert-value">{a.value}</span>
|
<span className="sp-alert-value">{a.value}</span>
|
||||||
<span className="sp-muted sp-small">seit {formatSince(a.since)}</span>
|
</div>
|
||||||
|
<span className="muted small nowrap">seit {formatSince(a.since)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sp-tiles" data-testid="sp-tiles">
|
<section className="stats sp-tiles" data-testid="sp-tiles">
|
||||||
{METRICS.map((x) => (
|
{METRICS.map((x) => (
|
||||||
<Tile key={x.id} id={x.id} label={x.label} model={tileModel(x.id, overview?.host?.[x.id])} />
|
<Tile key={x.id} id={x.id} label={x.label} model={tileModel(x.id, overview?.host?.[x.id])} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sp-panel" data-testid="sp-chart-panel">
|
<section className="sp-panel" data-testid="sp-chart-panel">
|
||||||
<div className="sp-panel-head">
|
<h2 className="section"><span>Verlauf</span></h2>
|
||||||
<h3>Verlauf</h3>
|
|
||||||
<div className="sp-controls">
|
<div className="sp-controls">
|
||||||
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
|
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
|
||||||
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
|
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{chartData && chartData.data[0].length > 0
|
{chartData && chartData.data[0].length > 0
|
||||||
? <Chart data={chartData.data} series={chartData.series} yFmt={m.fmt} />
|
? <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>
|
||||||
|
|
||||||
<section className="sp-panel" data-testid="sp-projects">
|
<section className="sp-panel" data-testid="sp-projects">
|
||||||
<div className="sp-panel-head">
|
<h2 className="section">
|
||||||
<h3>Projekte</h3>
|
<span>Projekte</span>
|
||||||
{appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>}
|
{appsData?.sizesDay && <span className="muted small">Größen von {appsData.sizesDay}</span>}
|
||||||
</div>
|
</h2>
|
||||||
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
|
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="sp-detail" data-testid="sp-detail">
|
<div className="sp-detail" data-testid="sp-detail">
|
||||||
<div className="sp-panel-head">
|
<h3 className="sp-detail-title">{detail?.name || selected}</h3>
|
||||||
<h3>{detail?.name || selected}</h3>
|
<div className="sp-controls">
|
||||||
<Pills items={RANGES} value={detailRange} onChange={changeDetailRange} testid="sp-detail-range" />
|
<Pills items={RANGES} value={detailRange} onChange={changeDetailRange} testid="sp-detail-range" />
|
||||||
</div>
|
</div>
|
||||||
{detailData ? (
|
{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">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-chart-title">CPU</div><Chart data={detailData.cpu.data} series={detailData.cpu.series} yFmt={(v) => formatPct(v)} height={200} /></div>
|
||||||
</div>
|
</div>
|
||||||
) : <div className="sp-muted sp-nodata">Lädt …</div>}
|
) : <div className="muted sp-nodata">Lädt …</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sp-panel" data-testid="sp-cleanup">
|
<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} />
|
<Cleanup data={cleanup} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+27
-29
@@ -1,14 +1,12 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { dotColor } from '../lib/previewEnv'
|
|
||||||
import { fetchServices } from '../lib/projectKindsApi'
|
import { fetchServices } from '../lib/projectKindsApi'
|
||||||
import { servicesCards } from '../lib/servicesView'
|
import { servicesCards } from '../lib/servicesView'
|
||||||
import { ticketPath } from '../lib/ticketRoutes'
|
import { ticketPath } from '../lib/ticketRoutes'
|
||||||
|
import { previewStateTone } from '../components/projectTones'
|
||||||
import { PageHeader, Card, Button, Badge } from '../components/ui'
|
import { PageHeader, Card, Button, Badge } from '../components/ui'
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -28,59 +26,59 @@ export default function ServicesPage() {
|
|||||||
|
|
||||||
const cards = useMemo(() => servicesCards(data), [data])
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Services" subtitle="Unsere Projekte nach Art" />
|
<PageHeader title="Services" subtitle="Unsere Projekte nach Art" />
|
||||||
{error && (
|
{error && (
|
||||||
<Card>
|
<div className="services-stack">
|
||||||
<div className="text-red" style={{ marginBottom: 8 }}>{error}</div>
|
<p className="msg err" role="alert">{error}</p>
|
||||||
<Button onClick={load}>Erneut versuchen</Button>
|
<div className="row-buttons">
|
||||||
</Card>
|
<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) => (
|
{cards.map((c) => (
|
||||||
<Card key={c.key} title={`${c.label} (${c.count})`}>
|
<Card key={c.key} title={`${c.label} (${c.count})`}>
|
||||||
{c.description && <div className="muted" style={{ marginBottom: 8 }}>{c.description}</div>}
|
<div className="services-stack">
|
||||||
|
{c.description && <p className="muted">{c.description}</p>}
|
||||||
{c.features.length > 0 && (
|
{c.features.length > 0 && (
|
||||||
<div className="flex gap-2 wrap" style={{ marginBottom: 8 }}>
|
<div className="pv-row">
|
||||||
{c.features.map((f) => (
|
{c.features.map((f) => (
|
||||||
<Badge key={f.label}>{f.label}{f.statusLabel ? ` · ${f.statusLabel}` : ''}</Badge>
|
<Badge key={f.label}>{f.label}{f.statusLabel ? ` · ${f.statusLabel}` : ''}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{c.projects.length > 0 ? (
|
||||||
<div className="list">
|
<div className="list">
|
||||||
{c.projects.map((p) => (
|
{c.projects.map((p) => (
|
||||||
<div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}>
|
<div key={p.id} className="list-row">
|
||||||
<div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
<div className="list-row-text">
|
||||||
<div style={{ fontWeight: 700 }}>
|
<div className="cell-title">
|
||||||
{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.projectName || p.repoFullName}
|
||||||
|
{p.previewState && <Badge tone={previewStateTone(p.previewState)}>Preview: {p.previewState}</Badge>}
|
||||||
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
|
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="muted">
|
<div className="cell-sub">
|
||||||
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{p.woid && <Link to={ticketPath(p.woid)}>Ticket #{p.woid}</Link>}
|
{(p.woid || c.key === 'missing') && (
|
||||||
{c.key === 'missing' && (
|
<div className="row-actions">
|
||||||
<Button variant="primary" onClick={() => navigate(`/projects/zuordnen/${p.id}`)}>Zuordnen</Button>
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{c.projects.length === 0 && <div className="muted" style={{ padding: 12 }}>Noch keine Projekte.</div>}
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted pick-empty">Noch keine Projekte.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
|
import { PageHeader, Card, Badge, Button, EmptyState } from '../components/ui'
|
||||||
import { PageHeader, Card, Badge, Button } from '../components/ui'
|
|
||||||
import { listStorageTasks, markStorageDone } from '../lib/teamApi'
|
import { listStorageTasks, markStorageDone } from '../lib/teamApi'
|
||||||
import { fmtDate, fmtDateTime } from '../lib/format'
|
import { fmtDateTime } from '../lib/format'
|
||||||
import { changeText, failedText, isOverdue, orderedKind, partialText, scopeText, summaryText, taskText } from '../lib/storageFormat'
|
import { changeText, failedText, orderedKind, partialText, scopeText, storageDue, summaryText, taskText } from '../lib/storageFormat'
|
||||||
import '../styles/admin-areas.css'
|
|
||||||
|
|
||||||
// E-Mail-Speicher (Kunden -> E-Mail-Speicher): Zusatzspeicher-Aufgaben in konsoleH wie im frueheren Admin-Panel
|
// E-Mail-Speicher (Kunden -> E-Mail-Speicher): Zusatzspeicher-Aufgaben in konsoleH wie im frueheren Admin-Panel
|
||||||
export default function StorageTasksPage() {
|
export default function StorageTasksPage() {
|
||||||
@@ -39,57 +37,76 @@ export default function StorageTasksPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="E-Mail-Speicher" subtitle="Zusatzspeicher der Abos in konsoleH einrichten und senken" />
|
<PageHeader title="E-Mail-Speicher" subtitle="Zusatzspeicher der Abos in konsoleH einrichten und senken" />
|
||||||
{error && (
|
{error && (
|
||||||
<div className="area-error area-inline">
|
<div className="flex items-center gap-2 wrap mb-2">
|
||||||
{error} <Button size="sm" variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
|
<span className="msg err" role="alert">{error}</span>
|
||||||
|
<Button onClick={load}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!data && !error && <FaSpinner className="spinner" />}
|
{!data && !error && <div className="loading">Wird geladen …</div>}
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className={`area-summary ${summary.warn ? 'warn' : ''}`}>{summary.text}</div>
|
<p className={summary.warn ? 'msg warn mb-2' : 'muted mb-2'}>{summary.text}</p>
|
||||||
<Card pad={false}>
|
{data.tasks.length === 0 ? (
|
||||||
<div className="area-rows">
|
<EmptyState title="Keine offenen Speicher-Aufgaben." />
|
||||||
<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>}
|
<table className="table table-wrap">
|
||||||
{data.tasks.map((t) => (
|
<thead>
|
||||||
<div key={t.id} className="area-row storage-row">
|
<tr>
|
||||||
<span>
|
<th className="hide-sm">Fällig</th>
|
||||||
{t.kind === 'einrichten'
|
<th className="col-name">Kunde</th>
|
||||||
? <><Badge tone="warn">jetzt einrichten</Badge><br /><span className="area-muted">bestellt {fmtDateTime(t.createdAt)}</span></>
|
<th>Aufgabe in konsoleH</th>
|
||||||
: <><Badge tone={isOverdue(t) ? 'warn' : 'muted'}>zum Periodenende</Badge><br />{fmtDate(t.dueAt)}</>}
|
<th className="hide-md">Bestellt</th>
|
||||||
</span>
|
<th />
|
||||||
<span>
|
</tr>
|
||||||
<Link to={`/customers/${t.customerId}`}><strong>{t.customerName}</strong></Link>
|
</thead>
|
||||||
{t.customerCode && <><br /><span className="area-muted">Kd.-Nr. {t.customerCode}</span></>}
|
<tbody>
|
||||||
{t.email && <><br /><span className="area-muted">{t.email}</span></>}
|
{data.tasks.map((t) => {
|
||||||
<br /><span className="area-muted">{t.productLabel}</span>
|
const due = storageDue(t)
|
||||||
</span>
|
return (
|
||||||
<span>
|
<tr key={t.id}>
|
||||||
<strong>{taskText(t)}</strong>
|
<td className="hide-sm">
|
||||||
<br /><span className="area-muted">{scopeText(t)}</span>
|
<Badge tone={due.tone}>{due.label}</Badge>
|
||||||
<br /><span className="area-muted">{changeText(t)}</span>
|
<div className={due.subMuted ? 'cell-sub' : undefined}>{due.sub}</div>
|
||||||
{t.partial && <><br /><span className="area-danger">{partialText(t)}</span></>}
|
</td>
|
||||||
</span>
|
<td>
|
||||||
<span>{fmtDateTime(t.createdAt)}<br /><span className="area-muted">{orderedKind(t)}</span></span>
|
<div className="cell-title"><Link className="link" to={`/customers/${t.customerId}`}>{t.customerName}</Link></div>
|
||||||
<span>
|
{t.customerCode && <div className="cell-sub">Kd.-Nr. {t.customerCode}</div>}
|
||||||
<Button size="sm" variant="primary" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
|
{t.email && <div className="cell-sub">{t.email}</div>}
|
||||||
{busyId === t.id ? <FaSpinner className="spinner" /> : 'Erledigt'}
|
<div className="cell-sub">{t.productLabel}</div>
|
||||||
</Button>
|
</td>
|
||||||
</span>
|
<td>
|
||||||
</div>
|
<div className="cell-title">{taskText(t)}</div>
|
||||||
))}
|
<div className="cell-sub">{scopeText(t)}</div>
|
||||||
</div>
|
<div className="cell-sub">{changeText(t)}</div>
|
||||||
</Card>
|
{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 && (
|
{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) => (
|
{data.failed.map((f) => (
|
||||||
<div key={f.id} style={{ marginBottom: 6 }}>
|
<div key={f.id} className="list-row">
|
||||||
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="area-muted">{failedText(f)}</span>
|
<div className="list-row-text">
|
||||||
|
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="muted">{failedText(f)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</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.
|
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>
|
</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 im Ticketsystem (Messenger und Reiter "Chat") im weissen Design.
|
||||||
chat-composer.css des Kundenportals; hier Farben (--chat-*), Verlauf, Sprechblasen und die Liste. */
|
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 {
|
.woms-chat {
|
||||||
--chat-accent: var(--accent);
|
--chat-accent: var(--accent);
|
||||||
--chat-accent-hover: var(--accent-hover);
|
--chat-accent-hover: var(--accent-hover);
|
||||||
@@ -10,50 +17,121 @@
|
|||||||
--chat-surface: var(--bg);
|
--chat-surface: var(--bg);
|
||||||
--chat-line: var(--line-strong);
|
--chat-line: var(--line-strong);
|
||||||
--chat-muted: var(--text-2);
|
--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;
|
display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Verlauf: scrollt in sich, ohne Rand und Hintergrund */
|
||||||
.woms-chat .chat-messages {
|
.woms-chat .chat-messages {
|
||||||
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto;
|
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 0;
|
||||||
padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Blasen: fremde hellgrau, eigene etwas dunkler und rechts, kein Rand. .chat-bubble 0,2,0 > 0,1,0 */
|
||||||
.woms-chat .chat-bubble {
|
.woms-chat .chat-bubble {
|
||||||
align-self: flex-start; max-width: 78%; padding: 8px 12px; border-radius: 14px;
|
align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--radius-lg);
|
||||||
background: var(--surface-2); color: var(--text); overflow-wrap: anywhere;
|
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-bubble.mine { align-self: flex-end; background: var(--chat-bubble-out); }
|
||||||
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 11.5px; color: var(--text-muted); }
|
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 12px; color: var(--text-2); }
|
||||||
.woms-chat .chat-notice { font-size: 12px; }
|
.woms-chat .chat-notice { font-size: 12px; color: var(--text-2); }
|
||||||
/* Meldungen des Eingabebausteins (Stile aus chat-composer.css des Portals): Fehler rot, Info grau, beides lesbar auf Weiss */
|
|
||||||
|
/* 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-composer-note,
|
||||||
.woms-chat .chat-tray-item.is-error .chat-file-size,
|
.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-audio.is-error .chat-audio-time { color: var(--red-fg); }
|
||||||
.woms-chat .chat-composer-note.is-info { color: var(--text-2); }
|
.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; }
|
/* Eingabefeld: Ring statt Rand, Hoehe und Radius wie bisher. 0,2,1 > 0,1,1; :focus 0,3,1 */
|
||||||
.chat-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
|
.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 { display: flex; flex-direction: column; max-height: 70vh; overflow-y: auto; }
|
||||||
.chat-list-item {
|
.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;
|
background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer;
|
||||||
}
|
}
|
||||||
.chat-list-item:hover { background: var(--surface-2); }
|
.chat-list-item:first-child { border-top: 1px solid var(--line); }
|
||||||
.chat-list-item.active { background: var(--accent-soft); }
|
.chat-list-item:hover { background: var(--bg-subtle); }
|
||||||
.chat-list-top { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
.chat-list-item[aria-current="true"] { background: var(--accent-soft); }
|
||||||
.chat-list-last { color: var(--text-muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.chat-list-top { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
|
||||||
.chat-list-time { color: var(--text-faint); font-size: 11.5px; }
|
.chat-list-top strong { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
.chat-main { min-width: 0; }
|
.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-back { display: none; }
|
.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 { grid-template-columns: minmax(0, 1fr); }
|
||||||
.chat-page.has-thread .chat-side { display: none; }
|
.chat-page.has-thread .chat-side { display: none; }
|
||||||
.chat-page:not(.has-thread) .chat-main { 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%; }
|
.woms-chat .chat-bubble { max-width: 90%; }
|
||||||
}
|
}
|
||||||
+411
-286
@@ -500,11 +500,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
|||||||
|
|
||||||
.hidden { display: none; }
|
.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 {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@@ -606,225 +601,187 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Rechtliches --- */
|
/* --- 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-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-grid > .legal-error { grid-column: 1 / -1; }
|
.legal-toggle { margin-left: auto; font-size: 13px; color: var(--text-2); }
|
||||||
.legal-doc-summary { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; cursor: pointer; }
|
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; }
|
||||||
.legal-toggle { margin-left: auto; font-size: 13px; }
|
.legal-history { list-style: none; margin: 0; padding: 0; }
|
||||||
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; color: var(--accent); }
|
.legal-version { padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||||
.legal-history { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
|
.legal-version.is-superseded { color: var(--text-2); }
|
||||||
.legal-version { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 8px 10px; font-size: 14px; }
|
.legal-version-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
.legal-version-current { border-color: var(--ok); background: rgba(16, 185, 129, 0.08); }
|
.legal-version-head .link { margin-left: auto; }
|
||||||
.legal-version-draft { border-style: dashed; }
|
.legal-note { white-space: pre-wrap; margin: 4px 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||||
.legal-version-superseded { color: var(--text-muted); }
|
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; overflow-wrap: anywhere; }
|
||||||
.legal-version-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
.legal-files .link { font-weight: 400; }
|
||||||
.legal-note { white-space: pre-wrap; margin: 4px 0; color: var(--text-muted); font-size: 13px; }
|
.legal-files .legal-dl { color: var(--text-2); }
|
||||||
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; }
|
.legal-msg { margin: 0 0 12px; }
|
||||||
.legal-files a { color: var(--accent); }
|
.legal-doc h2.section .link { white-space: nowrap; } /* "Neue Version" bleibt am Handy eine Zeile */
|
||||||
.legal-error { background: var(--danger); color: #fff; padding: 8px 12px; border-radius: var(--r-sm); margin: 8px 0; }
|
.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-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
|
.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 { margin-top: var(--sp-2); }
|
||||||
.legal-contracts tbody tr { cursor: pointer; }
|
.legal-contracts .cell-title .link { text-align: left; } /* Knopf zentriert sonst lange Anbieternamen */
|
||||||
.legal-tone-red td { background: rgba(239, 68, 68, 0.10); }
|
.legal-contracts tr.row:has(+ .legal-detail) td { border-bottom: 0; } /* offene Zeile und Detail bilden einen Block, die Haarlinie steht darunter */
|
||||||
.legal-tone-red .legal-deadline { color: var(--danger); font-weight: 700; }
|
.legal-detail td { padding: 0 0 16px; }
|
||||||
.legal-tone-orange td { background: rgba(245, 158, 11, 0.10); }
|
.legal-facts { display: none; margin-bottom: 8px; }
|
||||||
.legal-tone-orange .legal-deadline { color: var(--orange-fg); font-weight: 600; }
|
.legal-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 8px; }
|
||||||
.legal-tone-missing .legal-deadline { color: var(--text-faint); font-style: italic; }
|
.legal-cancel { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
.legal-tone-grey td { color: var(--text-faint); }
|
.legal-cancel .input { width: 160px; }
|
||||||
.legal-detail td { background: var(--surface-2); cursor: default; }
|
@media (max-width: 860px) {
|
||||||
.legal-actions { margin-top: 8px; flex-wrap: wrap; }
|
.legal-facts { display: grid; }
|
||||||
@media (max-width: 700px) {
|
|
||||||
.legal-grid { grid-template-columns: 1fr; }
|
|
||||||
.legal-form-row { grid-template-columns: 1fr; }
|
|
||||||
.legal-contracts { display: block; overflow-x: auto; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== FINANZEN (P7) ===== */
|
||||||
/* --- Finanzen --- */
|
/* --- 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-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-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-neg, .fin-flag { color: var(--red-fg); }
|
||||||
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); align-items: start; }
|
.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) {
|
@media (min-width: 1100px) {
|
||||||
.fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
|
.fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
|
||||||
.fin-cols-half { grid-template-columns: repeat(2, 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) */
|
/* Block 0: Statuszeile (ohne Kasten) */
|
||||||
.fin-forecast-card { container-type: inline-size; overflow: hidden; }
|
.fin-status-wrap > .fin-notice { margin: 8px 0 0; }
|
||||||
.fin-table-wrap { overflow-x: auto; }
|
.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-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; }
|
.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.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-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
.fin-forecast-label { overflow-wrap: anywhere; }
|
.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 { display: grid; gap: 6px; margin-top: 12px; }
|
||||||
.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
|
|
||||||
@container (max-width: 560px) {
|
@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 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, .fin-forecast tbody { display: block; }
|
||||||
.fin-forecast tr {
|
.fin-forecast tr {
|
||||||
display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
|
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";
|
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-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 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-date { grid-area: date; }
|
||||||
.fin-forecast-kind { grid-area: kind; }
|
.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-label { grid-area: label; }
|
||||||
.fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
|
.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-sicher { grid-area: sicher; }
|
||||||
.fin-forecast-erwartet { grid-area: erwartet; }
|
.fin-forecast-erwartet { grid-area: erwartet; }
|
||||||
.fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); }
|
.fin-forecast td.fin-forecast-sicher { padding-right: 12px; }
|
||||||
.fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); }
|
.fin-forecast td.fin-forecast-erwartet { padding-left: 12px; }
|
||||||
.fin-saldo { margin-left: auto; }
|
.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 */
|
/* Block 4: Steuer (Liste mit Haarlinien; die Fussnote steht nach dem letzten Posten) */
|
||||||
.fin-facts { display: grid; gap: var(--sp-4); }
|
.fin-facts { display: grid; gap: 16px; }
|
||||||
.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
|
.fin-fact { display: grid; gap: 3px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
||||||
.fin-fact:last-child { padding-bottom: 0; border-bottom: none; }
|
.fin-fact:last-of-type { padding-bottom: 0; border-bottom: 0; }
|
||||||
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); }
|
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||||
.fin-fact-label { color: var(--text-muted); font-size: 13px; }
|
.fin-fact-label { color: var(--text-2); font-size: 13px; }
|
||||||
.fin-fact-big { font-size: 24px; font-weight: 800; }
|
.fin-fact-big { font-size: 22px; line-height: 30px; font-weight: 600; }
|
||||||
.fin-fact-strong { font-size: 16px; font-weight: 700; }
|
.fin-fact-strong { font-size: 16px; font-weight: 600; }
|
||||||
.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
.fin-fact-value { font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||||
.fin-fact-unknown { color: var(--warn); }
|
.fin-fact-unknown { color: var(--orange-fg); }
|
||||||
.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
.fin-fact-sub { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||||
.fin-fact-sub-strong { color: var(--text); }
|
.fin-fact-sub-strong { color: var(--text); }
|
||||||
|
|
||||||
/* Block 5: Abos */
|
/* Block 5: Abos (Kennzahlen: .stats.fin-abo-stats ohne eigene Regel) */
|
||||||
.fin-abo-card { overflow: hidden; }
|
.fin-abos { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||||
.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-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-stat-value { font-size: 20px; font-weight: 800; }
|
.fin-abo-customer { font-weight: 600; overflow-wrap: anywhere; }
|
||||||
.fin-stat-label { color: var(--text-muted); }
|
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; }
|
||||||
.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 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
|
/* Block 6 und 7: Rechnungen und Buchungen. Beide Tabellen liegen in .fin-body (Container); schmal wird jede Zeile ein Raster. */
|
||||||
.fin-block { scroll-margin-top: var(--sp-4); }
|
.fin-block .seg { flex-wrap: wrap; max-width: 100%; }
|
||||||
.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }
|
.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-toolbar { padding: var(--sp-3) var(--sp-4); }
|
.fin-ages .stat .v { font-size: 18px; line-height: 26px; }
|
||||||
.fin-toolbar .seg { flex-wrap: wrap; max-width: 100%; }
|
.fin-pager { justify-content: center; flex-wrap: wrap; gap: 16px; }
|
||||||
.fin-notice { padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
.fin-pager-info { font-variant-numeric: tabular-nums; }
|
||||||
.fin-load-error { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-4); }
|
.fin-block button.link:disabled, .fin-block button.link-danger:disabled { cursor: not-allowed; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
.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.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-inv-number { font-weight: 500; white-space: nowrap; }
|
||||||
.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-inv-customer, .fin-tx-counterparty { overflow-wrap: anywhere; }
|
||||||
.fin-invoices tbody tr:last-child td, .fin-tx tbody tr:last-child td { border-bottom: none; }
|
.fin-tx-counterparty { font-weight: 500; }
|
||||||
.fin-inv-number { font-weight: 700; white-space: nowrap; }
|
|
||||||
.fin-inv-customer { overflow-wrap: anywhere; }
|
|
||||||
.fin-inv-created, .fin-inv-due, .fin-tx-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
.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-statusbox { display: grid; gap: 3px; justify-items: start; }
|
||||||
.fin-inv-note { font-size: 12px; }
|
.fin-inv-note { font-size: 12px; }
|
||||||
.fin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
.fin-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||||
.fin-tx-counterparty { font-weight: 600; overflow-wrap: anywhere; }
|
|
||||||
.fin-tx-remittance {
|
.fin-tx-remittance {
|
||||||
font-size: 13px; overflow-wrap: anywhere;
|
font-size: 13px; overflow-wrap: anywhere;
|
||||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
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-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-suggestion, .fin-tx-note { font-size: 12px; margin-top: 4px; overflow-wrap: anywhere; }
|
||||||
.fin-tx-action { white-space: nowrap; text-align: right; }
|
.fin-tx-action { white-space: nowrap; text-align: right; }
|
||||||
@container (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.fin-block .seg button { padding: 0 9px; }
|
||||||
.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; }
|
|
||||||
.fin-invoices tr {
|
|
||||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px var(--sp-4); 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);
|
|
||||||
}
|
}
|
||||||
.fin-invoices tbody tr:last-child { border-bottom: none; }
|
@container (max-width: 860px) {
|
||||||
.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; }
|
.stats.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.fin-invoices tbody tr:hover td, .fin-tx tbody tr:hover td { background: transparent; }
|
.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 16px; align-items: baseline;
|
||||||
|
grid-template-areas: "number status" "customer customer" "created due" "total remaining" "actions actions";
|
||||||
|
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.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-number { grid-area: number; }
|
||||||
.fin-inv-status { grid-area: status; }
|
.fin-inv-status { grid-area: status; }
|
||||||
.fin-inv-statusbox { justify-items: end; text-align: right; }
|
.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-total { grid-area: total; }
|
||||||
.fin-inv-remaining { grid-area: remaining; }
|
.fin-inv-remaining { grid-area: remaining; }
|
||||||
.fin-inv-actions { grid-area: actions; padding-top: 4px; }
|
.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] { 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-muted); font-weight: 500; }
|
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-2); font-weight: 500; }
|
||||||
}
|
}
|
||||||
@container (max-width: 640px) {
|
@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 {
|
.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";
|
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-date { grid-area: date; }
|
||||||
.fin-tx-amount { grid-area: amount; }
|
.fin-tx-amount { grid-area: amount; }
|
||||||
.fin-tx-what { grid-area: what; }
|
.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; }
|
.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), */
|
/* Dialoge: Rechnung schreiben, Buchung zuordnen. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
|
||||||
/* .overlay-content, .overlay-close); hier nur, was dort fehlt. */
|
.fin-fixed-customer { overflow-wrap: anywhere; }
|
||||||
.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
|
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; }
|
||||||
.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
|
.fin-address-warn-page { margin: 0 0 16px; }
|
||||||
.fin-dialog { max-width: 760px; }
|
.fin-address-warn-dialog { margin: 0; }
|
||||||
.fin-dialog:focus { outline: none; }
|
/* Rechnung schreiben: Positionen, Summe, Zahlungsziel und Notiz */
|
||||||
.fin-dialog-error {
|
.fin-items { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||||
margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
|
.fin-items legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
|
||||||
border: 1px solid var(--danger); background: var(--red-bg); color: var(--red-fg); overflow-wrap: anywhere;
|
.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-dialog-warn { margin: 6px 0 0; font-size: 13px; color: var(--orange-fg); overflow-wrap: anywhere; }
|
.fin-item + .fin-item { margin-top: 8px; }
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.fin-item-field { min-width: 0; }
|
.fin-item-field { min-width: 0; }
|
||||||
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); }
|
.fin-item .input { width: 100%; }
|
||||||
.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 .fin-amount { text-align: right; }
|
||||||
.fin-item .form-control.fin-amount { text-align: right; }
|
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-2); }
|
||||||
.fin-item-total { display: block; padding: 10px 0; font-weight: 600; }
|
.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-icon-btn {
|
.fin-item-sum { text-align: right; }
|
||||||
width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
.fin-item-total { display: block; line-height: 32px; font-weight: 600; }
|
||||||
background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted);
|
.fin-item-remove { display: flex; align-items: center; height: 32px; }
|
||||||
}
|
.fin-item-remove button:disabled { cursor: not-allowed; }
|
||||||
.fin-icon-btn:hover:not(:disabled) { color: var(--red-fg); border-color: var(--red-fg); }
|
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
||||||
.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
.fin-total { display: flex; align-items: baseline; gap: 16px; font-size: 18px; font-weight: 600; }
|
||||||
.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 12px; }
|
||||||
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
|
/* Zuordnen (Dialog): Fuss, Fakten, Auswahlzeilen, Bestaetigung. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
|
||||||
.fin-total { display: flex; align-items: baseline; gap: var(--sp-4); font-size: 18px; font-weight: 700; }
|
.fin-foot { flex: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-4); }
|
.kv .fin-tx-facts-amount { text-align: left; font-weight: 600; }
|
||||||
.fin-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-3); margin-top: var(--sp-4); }
|
.fin-assigned { margin: 0; }
|
||||||
.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-choices { border: 0; padding: 0; margin: 0; display: grid; min-width: 0; }
|
||||||
.fin-tx-facts > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
|
.fin-choices legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
|
||||||
.fin-tx-facts dt { color: var(--text-muted); font-size: 13px; }
|
.fin-choice { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||||
.fin-tx-facts dd { margin: 0; overflow-wrap: anywhere; }
|
.fin-choice input { flex: none; margin: 3px 0 0; }
|
||||||
.fin-tx-facts .fin-tx-facts-amount { text-align: left; font-weight: 700; }
|
.fin-choice-off { opacity: 0.55; cursor: not-allowed; }
|
||||||
.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-choice-text { display: grid; gap: 2px; min-width: 0; }
|
.fin-choice-text { display: grid; gap: 2px; min-width: 0; }
|
||||||
.fin-choice-label { font-weight: 600; overflow-wrap: anywhere; }
|
.fin-choice-label { color: var(--text); overflow-wrap: anywhere; }
|
||||||
.fin-choice-detail { font-size: 13px; overflow-wrap: anywhere; }
|
.fin-choice:has(input:checked) .fin-choice-label { font-weight: 600; }
|
||||||
.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-choice-detail { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||||
.fin-confirm:focus { outline: 2px solid var(--warn); outline-offset: 2px; }
|
.fin-choice-detail.warn { color: var(--orange-fg); }
|
||||||
.fin-confirm p { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
|
.fin-confirm { font-weight: 600; color: var(--orange-fg); overflow-wrap: anywhere; }
|
||||||
@media (max-width: 640px) {
|
.fin-confirm p { margin: 0; }
|
||||||
.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"; }
|
@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-desc { grid-area: desc; }
|
||||||
.fin-item-qty { grid-area: qty; }
|
.fin-item-qty { grid-area: qty; }
|
||||||
.fin-item-price { grid-area: price; }
|
.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-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-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) */
|
/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */
|
||||||
.fin-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
|
.fin-fieldset { display: grid; gap: 16px; 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 .field-row { gap: 16px; }
|
||||||
.fin-form-field { min-width: 0; }
|
.fin-form-field { min-width: 0; }
|
||||||
.fin-form-hint { font-size: 12px; overflow-wrap: anywhere; }
|
/* KOMPAT setzt .page .field auf 12 px Abstand unten; im Raster bestimmt allein der gap den Abstand */
|
||||||
.fin-field-error { font-size: 13px; font-weight: 600; color: var(--danger); overflow-wrap: anywhere; }
|
.page .fin-fieldset .field, .page .fin-fixed-form .field { margin-bottom: 0; }
|
||||||
.fin-input-amount { text-align: right; font-variant-numeric: tabular-nums; }
|
.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 { 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: 700; }
|
.fin-counter-over { color: var(--danger); font-weight: 600; }
|
||||||
.fin-form-notice { margin: 0 0 var(--sp-3); }
|
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 16px; }
|
||||||
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
|
.fin-kv { margin: 0 0 16px; }
|
||||||
.fin-bank-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-3); }
|
.fin-result { font-size: 16px; line-height: 24px; font-weight: 600; }
|
||||||
.fin-bank-facts > div { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
|
.fin-fixed-form { padding-bottom: 24px; margin-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||||
.fin-bank-facts dt { color: var(--text-muted); font-size: 13px; }
|
.fin-form-title { margin: 0 0 16px; font-size: 14px; line-height: 20px; font-weight: 600; }
|
||||||
.fin-bank-facts dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
.fin-fixed-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||||
.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 ~ .fin-fixed-list { border-top: 0; } /* das offene Formular schliesst mit seiner eigenen Linie ab */
|
||||||
.fin-fixed-form-title { margin: 0 0 var(--sp-3); font-size: 15px; font-weight: 700; }
|
.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-list { list-style: none; margin: 0; padding: 0; }
|
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-2); }
|
||||||
.fin-fixed {
|
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; }
|
||||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
|
.fin-fixed-name { font-weight: 500; overflow-wrap: anywhere; }
|
||||||
padding: var(--sp-3) 0; border-bottom: 1px solid var(--border);
|
.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: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-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
|
.fin-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
|
||||||
@media (max-width: 640px) {
|
.fin-fixed-actions button:disabled, .fin-notice button:disabled { cursor: not-allowed; }
|
||||||
.fin-form-grid { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
.fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
/* ===== ENDE FINANZEN ===== */
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Tickets (P2) ===== */
|
/* ===== Tickets (P2) ===== */
|
||||||
/* Arbeitsblatt-Zeilen, Zeilenlinks, Nachladen und Unterstuetzungs-Zeile. Steht direkt vor KOMPAT: gewinnt ueber die Bausteinregeln */
|
/* 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) ===== */
|
/* ===== 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) ===== */
|
/* ===== KOMPAT (bis P10) ===== */
|
||||||
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
|
/* 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 */
|
/* 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-grey { color: var(--text-2); }
|
||||||
.text-red { color: var(--red-fg); }
|
.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 */
|
/* Neutral: Karte, Raster */
|
||||||
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
|
.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; }
|
.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; }
|
.page .page { margin: 0; padding: 0; max-width: none; }
|
||||||
|
|
||||||
/* Links in Bestandsklassen, die bisher --accent als Farbe nutzten: feiner Unterstrich */
|
/* 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;
|
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 */
|
/* 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; }
|
.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 */
|
/* Altformulare mit <Field> (Kunde, Projekt zuweisen, Neuer Lead): Zeilenabstand wie frueher; neue Container bestimmen ihren Abstand selbst */
|
||||||
.page .field { margin-bottom: 12px; }
|
.page .field { margin-bottom: 12px; }
|
||||||
.dialog-body .field, .field-row .field, .field-row-3 .field, .kv .field { margin-bottom: 0; }
|
.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 ===== */
|
/* ===== ENDE KOMPAT ===== */
|
||||||
+45
-59
@@ -1,68 +1,54 @@
|
|||||||
/* Serverpuls (Admin) */
|
/* Serverpuls (P9): Kopf, Warnungen, Kacheln, Umschalter, Panels, Projekttabelle */
|
||||||
.sp-page { display: flex; flex-direction: column; gap: var(--sp-4); }
|
.sp-page .msg { margin: 0 0 16px; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
.sp-head { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); }
|
.sp-head-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.sp-level-medium { border-color: var(--orange-fg); }
|
.sp-headline { font-size: 16px; line-height: 24px; font-weight: 600; }
|
||||||
.sp-level-critical { border-color: var(--danger); }
|
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-2); }
|
||||||
.sp-head-top { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
.sp-age-stale { color: var(--orange-fg); font-weight: 500; }
|
||||||
.sp-headline { font-size: 18px; }
|
.sp-alerts { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||||
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-muted); }
|
.sp-alert-main { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; }
|
||||||
.sp-age-stale { color: var(--orange-fg); background: var(--orange-bg); padding: 2px 10px; border-radius: var(--r-pill); }
|
.sp-alert-title { font-weight: 500; overflow-wrap: anywhere; }
|
||||||
.sp-alerts { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
.sp-alert-value { color: var(--text-2); }
|
||||||
.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-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--sp-3); }
|
.stats.sp-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px 32px; }
|
||||||
@media (max-width: 1100px) { .sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
.sp-tile { min-width: 0; }
|
||||||
@media (max-width: 700px) { .sp-tiles { grid-template-columns: 1fr; } .sp-age { margin-left: 0; } }
|
.sp-tile .sub { min-height: 16px; }
|
||||||
.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; }
|
|
||||||
.sp-spark { display: block; max-width: 100%; margin-top: 6px; }
|
.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-panel { min-width: 0; }
|
||||||
.sp-delta-down { color: var(--ok); }
|
.sp-controls { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
|
||||||
.sp-delta-flat { color: var(--text-muted); }
|
.sp-controls .seg { flex-wrap: wrap; }
|
||||||
|
|
||||||
.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-chart { width: 100%; min-height: 200px; }
|
.sp-chart { width: 100%; min-height: 200px; }
|
||||||
.sp-chart .u-legend { color: var(--text-muted); font-size: 12px; }
|
.sp-chart .u-legend { font-size: 13px; color: var(--text-2); }
|
||||||
.sp-nodata { padding: var(--sp-6, 32px) 0; text-align: center; }
|
.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-sort { display: block; width: 100%; text-align: inherit; white-space: nowrap; }
|
||||||
.sp-table-wrap { overflow-x: auto; }
|
.sp-page .table td.num { white-space: nowrap; }
|
||||||
.sp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
.sp-name { overflow-wrap: anywhere; }
|
||||||
.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-name .link { text-align: left; overflow-wrap: anywhere; }
|
||||||
.sp-sortable { cursor: pointer; user-select: none; }
|
.sp-page .table tr.sp-row-active td { background: var(--bg-subtle); }
|
||||||
.sp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
.sp-page .table tr.sp-row-active td:first-child { box-shadow: -12px 0 0 var(--bg-subtle); }
|
||||||
.sp-row { cursor: pointer; }
|
.sp-page .table tr.sp-row-active td:last-child { box-shadow: 12px 0 0 var(--bg-subtle); }
|
||||||
.sp-row:hover, .sp-row-active { background: var(--surface-2); }
|
.sp-empty { text-align: center; color: var(--text-2); }
|
||||||
.sp-name { font-weight: 600; }
|
|
||||||
.sp-empty { text-align: center; color: var(--text-muted); }
|
|
||||||
.sp-delta { font-variant-numeric: tabular-nums; }
|
.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 { margin-top: 28px; }
|
||||||
.sp-detail-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); }
|
.sp-detail-title { margin: 0 0 8px; font-size: 15px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
.sp-chart-title { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
|
.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-list { list-style: none; margin: 12px 0; padding: 0; }
|
||||||
.sp-cleanup-grid > div { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.sp-cleanup-grid strong { font-size: 20px; }
|
@media (max-width: 1100px) {
|
||||||
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
|
.stats.sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
}
|
||||||
|
|
||||||
|
@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 assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
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 = [
|
const P = [
|
||||||
{ $id: 'a' },
|
{ $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, 'everything', shk, { isAdmin: true }), [], 'zeigt schon alles')
|
||||||
assert.deepEqual(hiddenMatches(list, 'all', (p) => matchesSearch(p, 'olz'), { isAdmin: true }), [])
|
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 assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
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', () => {
|
test('formatBytes: de-DE, Basis 1024', () => {
|
||||||
assert.equal(formatBytes(1288490189), '1,2 GB')
|
assert.equal(formatBytes(1288490189), '1,2 GB')
|
||||||
@@ -20,6 +20,19 @@ test('formatDelta', () => {
|
|||||||
assert.equal(formatDelta(0).dir, 'flat')
|
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.', () => {
|
test('ageLabel: stale ab > 15 Min.', () => {
|
||||||
const now = 1_000_000_000_000
|
const now = 1_000_000_000_000
|
||||||
const a16 = ageLabel(now - 16 * 60000, now)
|
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