- Introduced new API endpoints for managing report definitions, including listing, creating, and updating reports. - Updated the frontend to include a dedicated section for configuring reports, enhancing user navigation and experience. - Modified existing components to link to the new report settings, ensuring seamless access to report functionalities. - Improved the report catalog API to support multiple definitions per profile and added validation for report limits. - Updated documentation and tests to reflect the new features and ensure proper functionality.
630 lines
24 KiB
JavaScript
630 lines
24 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { Download, FileText, Plus, Save, Trash2 } from 'lucide-react'
|
||
import { api, formatFastApiDetail } from '../utils/api'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import { useProfile } from '../context/ProfileContext'
|
||
import UsageBadge from '../components/UsageBadge'
|
||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||
import {
|
||
BODY_CHART_DAYS_DEFAULT,
|
||
BODY_CHART_DAYS_MAX,
|
||
BODY_CHART_DAYS_MIN,
|
||
normalizeBodyChartDays,
|
||
} from '../widgetSystem/bodyChartDays'
|
||
|
||
const VIZ_BUNDLES_FALLBACK = [
|
||
{ id: 'body_history_viz', title: 'Körper (Verlauf-Bundle)' },
|
||
{ id: 'nutrition_history_viz', title: 'Ernährung (Verlauf-Bundle)' },
|
||
{ id: 'fitness_history_viz', title: 'Fitness (Verlauf-Bundle)' },
|
||
{ id: 'recovery_history_viz', title: 'Erholung (Verlauf-Bundle)' },
|
||
{ id: 'history_overview_viz', title: 'Gesamtübersicht (Korrelationen)' },
|
||
]
|
||
|
||
export default function ReportConfigurePage() {
|
||
const { canExport } = useAuth()
|
||
const { activeProfile } = useProfile()
|
||
const [exportUsage, setExportUsage] = useState(null)
|
||
const [catalog, setCatalog] = useState(null)
|
||
const [definitions, setDefinitions] = useState([])
|
||
const [selectedId, setSelectedId] = useState(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState(null)
|
||
const [err, setErr] = useState(null)
|
||
|
||
const chartDaysMin = catalog?.chart_days?.min ?? BODY_CHART_DAYS_MIN
|
||
const chartDaysMax = catalog?.chart_days?.max ?? BODY_CHART_DAYS_MAX
|
||
const vizBundles = catalog?.viz_bundles?.length ? catalog.viz_bundles : VIZ_BUNDLES_FALLBACK
|
||
|
||
const selected = definitions.find((d) => d.id === selectedId) || null
|
||
|
||
const load = useCallback(async () => {
|
||
setErr(null)
|
||
try {
|
||
const [cat, bundle] = await Promise.all([api.getReportsCatalog(), api.listReportDefinitions()])
|
||
setCatalog(cat)
|
||
setDefinitions(bundle.definitions || [])
|
||
} catch (e) {
|
||
setErr(formatFastApiDetail(null, e.message))
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
api.getFeatureUsage().then((features) => {
|
||
const exportFeature = features.find((f) => f.feature_id === 'data_export')
|
||
setExportUsage(exportFeature)
|
||
}).catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!activeProfile?.id) return
|
||
load()
|
||
}, [activeProfile?.id, load])
|
||
|
||
useEffect(() => {
|
||
if (!definitions.length) {
|
||
setSelectedId(null)
|
||
return
|
||
}
|
||
if (!selectedId || !definitions.some((d) => d.id === selectedId)) {
|
||
setSelectedId(definitions[0].id)
|
||
}
|
||
}, [definitions, selectedId])
|
||
|
||
const patchSelectedPayload = useCallback((fn) => {
|
||
setDefinitions((defs) =>
|
||
defs.map((d) => {
|
||
if (d.id !== selectedId) return d
|
||
const nextPayload = fn(d.payload || { version: 1, document_title: '', blocks: [] })
|
||
return { ...d, payload: nextPayload }
|
||
}),
|
||
)
|
||
}, [selectedId])
|
||
|
||
const reportNewBlock = (kind) => {
|
||
const charts = catalog?.charts || []
|
||
const first = charts[0]
|
||
const firstBundleId = vizBundles[0]?.id || 'body_history_viz'
|
||
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
|
||
if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} }
|
||
if (kind === 'chart')
|
||
return {
|
||
type: 'chart',
|
||
chart_id: first?.id || 'weight_trend',
|
||
window_days: first?.default_window_days || 28,
|
||
}
|
||
return { type: 'ai_insight', title: '', insight_id: null }
|
||
}
|
||
|
||
const handleCreateDefinition = async () => {
|
||
setBusy(true)
|
||
setErr(null)
|
||
setMsg(null)
|
||
try {
|
||
const r = await api.createReportDefinition({
|
||
name: `Bericht ${definitions.length + 1}`,
|
||
})
|
||
const d = r.definition
|
||
setDefinitions((x) => [...x, d])
|
||
setSelectedId(d.id)
|
||
setMsg('Neuer Bericht angelegt. Inhalt speichern nicht vergessen, wenn du Änderungen machst.')
|
||
} catch (e) {
|
||
setErr(formatFastApiDetail(null, e.message))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!selected) return
|
||
if (!selected.payload?.blocks?.length) {
|
||
setErr('Mindestens ein Block erforderlich.')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setErr(null)
|
||
setMsg(null)
|
||
try {
|
||
await api.updateReportDefinition(selected.id, {
|
||
name: selected.name?.trim() || 'Bericht',
|
||
payload: selected.payload,
|
||
})
|
||
setMsg('Gespeichert.')
|
||
await load()
|
||
} catch (e) {
|
||
setErr(formatFastApiDetail(null, e.message))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteDefinition = async () => {
|
||
if (!selected) return
|
||
if (!confirm(`Bericht „${selected.name}“ wirklich löschen?`)) return
|
||
setBusy(true)
|
||
setErr(null)
|
||
setMsg(null)
|
||
try {
|
||
await api.deleteReportDefinition(selected.id)
|
||
setDefinitions((defs) => defs.filter((d) => d.id !== selected.id))
|
||
setSelectedId(null)
|
||
setMsg('Bericht gelöscht.')
|
||
} catch (e) {
|
||
setErr(formatFastApiDetail(null, e.message))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleGeneratePdf = async () => {
|
||
if (!selected) return
|
||
setBusy(true)
|
||
setErr(null)
|
||
setMsg(null)
|
||
try {
|
||
await api.generateStructuredReportPdf(selected.id)
|
||
setMsg('PDF wurde heruntergeladen.')
|
||
} catch (e) {
|
||
setErr(formatFastApiDetail(null, e.message))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const setSelectedName = (name) => {
|
||
setDefinitions((defs) => defs.map((d) => (d.id === selectedId ? { ...d, name } : d)))
|
||
}
|
||
|
||
const vizBundleChartDays = (config) =>
|
||
normalizeBodyChartDays(config?.chart_days ?? BODY_CHART_DAYS_DEFAULT)
|
||
|
||
if (!catalog) {
|
||
return (
|
||
<div className="card section-gap">
|
||
<p style={{ color: 'var(--text2)' }}>Lade Katalog…</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="card section-gap">
|
||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<FileText size={18} color="var(--accent)" />
|
||
PDF-Berichte
|
||
</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
||
Hier legst du <strong>einen oder mehrere strukturierte PDF-Berichte</strong> an. Pro Block vom Typ
|
||
„Verlauf-Bundle“ gilt ein <strong>Zeitraum in Tagen</strong> (wie bei der Übersicht).{' '}
|
||
<strong>Technisch:</strong> Es sind dieselben{' '}
|
||
<strong>Daten-Bundles und dieselbe Konfiguration</strong> wie bei den Verlauf-Widgets — im PDF werden
|
||
sie serverseitig gerendert (nicht die React-Komponenten der Startseite).
|
||
</p>
|
||
{!canExport && (
|
||
<div
|
||
style={{
|
||
padding: '10px 12px',
|
||
background: '#FCEBEB',
|
||
borderRadius: 8,
|
||
fontSize: 13,
|
||
color: '#D85A30',
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
PDF ist mit dem Kontingent „Datenexport“ verknüpft. Bitte Admin kontaktieren.
|
||
</div>
|
||
)}
|
||
{err && (
|
||
<div
|
||
style={{
|
||
padding: '10px 12px',
|
||
borderRadius: 8,
|
||
fontSize: 13,
|
||
marginBottom: 12,
|
||
background: '#FCEBEB',
|
||
color: '#D85A30',
|
||
}}
|
||
>
|
||
{err}
|
||
</div>
|
||
)}
|
||
{msg && (
|
||
<div
|
||
style={{
|
||
padding: '10px 12px',
|
||
borderRadius: 8,
|
||
fontSize: 13,
|
||
marginBottom: 12,
|
||
background: '#E1F5EE',
|
||
color: 'var(--accent)',
|
||
}}
|
||
>
|
||
{msg}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 14 }}>
|
||
{definitions.map((d) => (
|
||
<button
|
||
key={d.id}
|
||
type="button"
|
||
className={d.id === selectedId ? 'btn btn-primary' : 'btn btn-secondary'}
|
||
onClick={() => setSelectedId(d.id)}
|
||
>
|
||
{d.name || 'Bericht'}
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy || !canExport || definitions.length >= 20}
|
||
onClick={handleCreateDefinition}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||
>
|
||
<Plus size={16} />
|
||
Neuer Bericht
|
||
</button>
|
||
<Link to="/settings" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||
← Allgemein
|
||
</Link>
|
||
</div>
|
||
|
||
{canExport && !definitions.length && (
|
||
<p style={{ fontSize: 14, marginBottom: 12 }}>
|
||
Noch kein Bericht vorhanden. Lege mit „Neuer Bericht“ einen Standard an.
|
||
</p>
|
||
)}
|
||
|
||
{canExport && selected && (
|
||
<>
|
||
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||
Name des Berichts (intern)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
maxLength={120}
|
||
value={selected.name || ''}
|
||
onChange={(e) => setSelectedName(e.target.value)}
|
||
style={{ marginBottom: 14, maxWidth: 420 }}
|
||
/>
|
||
|
||
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||
Dokumenttitel im PDF (optional)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
maxLength={120}
|
||
placeholder="Leer = Profilname + „Bericht“"
|
||
value={selected.payload?.document_title || ''}
|
||
onChange={(e) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
document_title: e.target.value,
|
||
}))
|
||
}
|
||
style={{ marginBottom: 14 }}
|
||
/>
|
||
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 8 }}>Blöcke</div>
|
||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||
{(selected.payload?.blocks || []).map((b, idx) => (
|
||
<li
|
||
key={idx}
|
||
style={{
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 10,
|
||
padding: 12,
|
||
marginBottom: 10,
|
||
background: 'var(--surface2)',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||
<span style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase' }}>{b.type}</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ padding: '4px 8px' }}
|
||
aria-label="Block entfernen"
|
||
onClick={() =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).filter((_, j) => j !== idx),
|
||
}))
|
||
}
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
{b.type === 'section' && (
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
style={{ marginTop: 8 }}
|
||
value={b.title || ''}
|
||
onChange={(e) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx ? { ...x, title: e.target.value } : x,
|
||
),
|
||
}))
|
||
}
|
||
/>
|
||
)}
|
||
{b.type === 'chart' && (
|
||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<select
|
||
className="form-input"
|
||
value={b.chart_id}
|
||
onChange={(e) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx ? { ...x, chart_id: e.target.value } : x,
|
||
),
|
||
}))
|
||
}
|
||
>
|
||
{catalog.charts?.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text3)' }}>Zeitraum (Tage)</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
min={7}
|
||
max={365}
|
||
value={b.window_days}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value)
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx
|
||
? { ...x, window_days: Number.isFinite(n) ? n : x.window_days }
|
||
: x,
|
||
),
|
||
}))
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{b.type === 'ai_insight' && (
|
||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
placeholder="Optional: Überschrift"
|
||
value={b.title || ''}
|
||
onChange={(e) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx ? { ...x, title: e.target.value } : x,
|
||
),
|
||
}))
|
||
}
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
placeholder="Optional: Insight-UUID"
|
||
value={b.insight_id || ''}
|
||
onChange={(e) =>
|
||
patchSelectedPayload((p) => {
|
||
const v = e.target.value.trim() || null
|
||
return {
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx ? { ...x, insight_id: v } : x,
|
||
),
|
||
}
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{b.type === 'viz_bundle' && (
|
||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
||
Verlauf-Bundle
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
value={b.bundle_id}
|
||
onChange={(e) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x,
|
||
),
|
||
}))
|
||
}
|
||
>
|
||
{vizBundles.map((vb) => (
|
||
<option key={vb.id} value={vb.id}>
|
||
{vb.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
||
Zeitraum für dieses Bundle (Tage): {chartDaysMin}–{chartDaysMax}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
style={{ maxWidth: 160 }}
|
||
min={chartDaysMin}
|
||
max={chartDaysMax}
|
||
value={vizBundleChartDays(b.config)}
|
||
onChange={(e) => {
|
||
const days = normalizeBodyChartDays(e.target.value)
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) =>
|
||
j === idx ? { ...x, config: { ...x.config, chart_days: days } } : x,
|
||
),
|
||
}))
|
||
}}
|
||
/>
|
||
</div>
|
||
{b.bundle_id === 'body_history_viz' && (
|
||
<BodyHistoryVizConfigEditor
|
||
config={b.config || {}}
|
||
onChange={(next) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) => {
|
||
if (j !== idx) return x
|
||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||
}),
|
||
}))
|
||
}
|
||
/>
|
||
)}
|
||
{b.bundle_id === 'nutrition_history_viz' && (
|
||
<NutritionHistoryVizConfigEditor
|
||
config={b.config || {}}
|
||
onChange={(next) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) => {
|
||
if (j !== idx) return x
|
||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||
}),
|
||
}))
|
||
}
|
||
/>
|
||
)}
|
||
{b.bundle_id === 'fitness_history_viz' && (
|
||
<FitnessHistoryVizConfigEditor
|
||
config={b.config || {}}
|
||
onChange={(next) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) => {
|
||
if (j !== idx) return x
|
||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||
}),
|
||
}))
|
||
}
|
||
/>
|
||
)}
|
||
{b.bundle_id === 'recovery_history_viz' && (
|
||
<RecoveryHistoryVizConfigEditor
|
||
config={b.config || {}}
|
||
onChange={(next) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) => {
|
||
if (j !== idx) return x
|
||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||
}),
|
||
}))
|
||
}
|
||
/>
|
||
)}
|
||
{b.bundle_id === 'history_overview_viz' && (
|
||
<HistoryOverviewVizConfigEditor
|
||
config={b.config || {}}
|
||
onChange={(next) =>
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: (p.blocks || []).map((x, j) => {
|
||
if (j !== idx) return x
|
||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||
}),
|
||
}))
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||
<select
|
||
className="form-input"
|
||
style={{ maxWidth: 280 }}
|
||
defaultValue=""
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
if (!v) return
|
||
patchSelectedPayload((p) => ({
|
||
...p,
|
||
blocks: [...(p.blocks || []), reportNewBlock(v)],
|
||
}))
|
||
e.target.value = ''
|
||
}}
|
||
>
|
||
<option value="">+ Block hinzufügen…</option>
|
||
<option value="section">Überschrift</option>
|
||
<option value="viz_bundle">Verlauf-Bundle (KPIs & Diagramme)</option>
|
||
<option value="chart">Einzel-Diagramm (Legacy)</option>
|
||
<option value="ai_insight">KI-Auswertung</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
disabled={busy}
|
||
onClick={handleSave}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||
>
|
||
<Save size={16} />
|
||
Speichern
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={handleDeleteDefinition}
|
||
>
|
||
Diesen Bericht löschen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={handleGeneratePdf}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||
>
|
||
<Download size={16} />
|
||
PDF erzeugen
|
||
</button>
|
||
</div>
|
||
{exportUsage && (
|
||
<div style={{ marginTop: 10 }}>
|
||
<UsageBadge {...exportUsage} />
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|