feat(notif): NOTIF-003 proaktive Wartungsfälligkeits-Benachrichtigung
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
d095db4172
commit
91cfb1087f
14 files changed
+386
-2
No files matched your search
@@ -80,7 +80,25 @@ ursprünglichen 16-Epic-Backlog, obwohl Karte 12 bereits vollständig implementi
|
||||
- **Akzeptanzkriterien:** fällige Wartung löst einmalig E-Mail+Dashboard-Eintrag aus,
|
||||
kein Mehrfachversand für dieselbe Fälligkeit.
|
||||
- **Tests:** Trigger-Test analog NOTIF-001, Mehrfachversand-Schutz-Test analog NOTIF-002.
|
||||
- **DoD:** offen. **Referenz (Open-Source-Vergleich 2026-09-09):** Shelf.nu nennt dies
|
||||
- **Granularität — entschieden 2026-09-10: pro Wartungsplan-Position, nicht
|
||||
pro Objekt.** Zwei unterschiedliche Wartungsarten am selben Objekt, die
|
||||
unabhängig voneinander fällig werden, sind zwei getrennte, eigenständig
|
||||
relevante Meldungen — eine Objekt-Ebene-Tracking-Zeile hätte die zweite
|
||||
Fälligkeit verschluckt, solange die erste schon gemeldet war.
|
||||
- **DoD:** ✅ umgesetzt (2026-09-10). `wartung_benachrichtigung`
|
||||
(Migration 0041, UNIQUE je objekt_id+wartungsplan_position_id) als
|
||||
Tracking-Tabelle, gelöscht beim Erledigen des Wartungsauftrags
|
||||
(`erledige_wartungsauftrag`) — nächste Fälligkeit derselben Position kann
|
||||
dann wieder benachrichtigen. `app/services/wartung.py:alle_faelligen_wartungen`
|
||||
als neue globale Aggregation (bisher nur objektbezogen über MAINT-002
|
||||
verfügbar), `app/services/wartung_benachrichtigung.py:pruefe_faellige_wartungen`
|
||||
als Prüflauf (gleiches E-Mail-Empfänger-Muster wie NOTIF-001),
|
||||
`scripts/wartung_benachrichtigung_pruefen.py` für einen systemd-Timer
|
||||
(`mabea-wartung-benachrichtigung.timer`, analog Eskalation/Readiness-Snapshot
|
||||
serverseitig einzurichten). `GET /dashboard/faellige-wartungen` +
|
||||
Dashboard-Kachel „Fällige Wartungen". `backend/tests/test_wartung_benachrichtigung.py`
|
||||
(Einmalversand + Mehrfachversand-Schutz-Test). **Referenz
|
||||
(Open-Source-Vergleich 2026-09-09):** Shelf.nu nennt dies
|
||||
„Schedule alerts for maintenance, calibration, warranty expiry" — bestätigt den
|
||||
Bedarf als verbreitetes Muster, MABEA-Umsetzung folgt dem eigenen NOTIF-001/002-Stil.
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""NOTIF-003: Tracking-Tabelle für Wartungsfälligkeits-Benachrichtigung.
|
||||
|
||||
Revision ID: 0041_wartung_benachrichtigung
|
||||
Revises: 0040_custody
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0041_wartung_benachrichtigung"
|
||||
down_revision: Union[str, None] = "0040_custody"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE wartung_benachrichtigung (
|
||||
id UUID PRIMARY KEY,
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
wartungsplan_position_id INTEGER NOT NULL REFERENCES wartungsplan_position(id),
|
||||
benachrichtigt_am TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (objekt_id, wartungsplan_position_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE wartung_benachrichtigung")
|
||||
@@ -7,6 +7,7 @@ from app.models.auth import RolleTyp
|
||||
from app.schemas.dashboard import (
|
||||
AblaufdatumRead,
|
||||
EinsatzbereitschaftRead,
|
||||
FaelligeWartungRead,
|
||||
KennzahlenRead,
|
||||
PrueftermineintragRead,
|
||||
QualifikationsablaufRead,
|
||||
@@ -18,6 +19,7 @@ from app.services.dashboard import (
|
||||
einsatzbereitschaft,
|
||||
kennzahlen,
|
||||
)
|
||||
from app.services.wartung import alle_faelligen_wartungen
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -58,6 +60,16 @@ async def hole_prueftermine(
|
||||
return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)]
|
||||
|
||||
|
||||
@router.get("/dashboard/faellige-wartungen", response_model=list[FaelligeWartungRead])
|
||||
async def hole_faellige_wartungen_global(
|
||||
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
|
||||
) -> list[FaelligeWartungRead]:
|
||||
"""NOTIF-003: globale Dashboard-Kachel "Fällige Wartungen" - ergänzt die
|
||||
bereits bestehende Objekt-bezogene Sicht (MAINT-002,
|
||||
/objekte/{id}/faellige-wartungen) um eine Übersicht über alle Objekte."""
|
||||
return [FaelligeWartungRead(**eintrag) for eintrag in await alle_faelligen_wartungen(db)]
|
||||
|
||||
|
||||
@router.get("/dashboard/qualifikationsablaeufe", response_model=list[QualifikationsablaufRead])
|
||||
async def hole_qualifikationsablaeufe(
|
||||
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.models.personal import (
|
||||
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
|
||||
from app.models.tankbuch import TankbuchEintrag
|
||||
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
|
||||
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
|
||||
from app.models.wartung import (
|
||||
IntervallTyp,
|
||||
Wartungsauftrag,
|
||||
@@ -106,4 +107,5 @@ __all__ = [
|
||||
"Wartungsintervall",
|
||||
"Wartungsplan",
|
||||
"WartungsplanPosition",
|
||||
"WartungBenachrichtigung",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class WartungBenachrichtigung(Base):
|
||||
"""NOTIF-003: Tracking-Zeile pro (objekt_id, wartungsplan_position_id) -
|
||||
Granularität bewusst je Position, nicht je Objekt (Entscheidung 2026-09-10):
|
||||
zwei unterschiedliche Wartungsarten am selben Objekt, die unabhängig
|
||||
voneinander fällig werden, sind zwei getrennte, eigenständig relevante
|
||||
Meldungen - eine einzige Objekt-Zeile würde die zweite Fälligkeit
|
||||
verschlucken, solange die erste schon gemeldet wurde. Zeile existiert =
|
||||
"für die aktuelle Fälligkeits-Episode bereits benachrichtigt", wird beim
|
||||
Erledigen des Wartungsauftrags gelöscht (siehe
|
||||
app/services/wartung.py:erledige_wartungsauftrag)."""
|
||||
|
||||
__tablename__ = "wartung_benachrichtigung"
|
||||
__table_args__ = (UniqueConstraint("objekt_id", "wartungsplan_position_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
|
||||
wartungsplan_position_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("wartungsplan_position.id"), nullable=False
|
||||
)
|
||||
benachrichtigt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -40,6 +41,21 @@ class EinsatzbereitschaftRead(BaseModel):
|
||||
details: list[ObjektMangelRead]
|
||||
|
||||
|
||||
class FaelligeWartungRead(BaseModel):
|
||||
"""NOTIF-003: globale Sicht über alle Objekte, gefiltert auf faellig=True -
|
||||
faellig_am/faellig_bei_km/faellig_bei_betriebsstunden sind je nach
|
||||
Intervall-Typ der Position gesetzt, die jeweils anderen bleiben None."""
|
||||
|
||||
objekt_id: int
|
||||
objekt_code: str
|
||||
objekt_name: str
|
||||
wartungsplan_position_id: int
|
||||
wartungsart: str
|
||||
faellig_am: date | None
|
||||
faellig_bei_km: int | None
|
||||
faellig_bei_betriebsstunden: Decimal | None
|
||||
|
||||
|
||||
class PrueftermineintragRead(BaseModel):
|
||||
"""typ='geraet' (Karte 14) oder 'fahrzeug_hu'/'fahrzeug_uvv' (Roadmap Phase
|
||||
3, Modul Fahrzeuge) - eine Kachel für beide (Nutzer-Vorgabe: "welche
|
||||
|
||||
@@ -57,3 +57,21 @@ async def benachrichtige_neuer_fehlbestand(db: AsyncSession, *, fehlbestand: Feh
|
||||
logger.exception("E-Mail-Versand für Fehlbestand %s fehlgeschlagen", fehlbestand.id)
|
||||
|
||||
return asyncio.create_task(_senden())
|
||||
|
||||
|
||||
async def benachrichtige_faellige_wartung(
|
||||
db: AsyncSession, *, objekt_id: int, objekt_name: str, wartungsart: str
|
||||
) -> asyncio.Task:
|
||||
"""NOTIF-003: gleiches Muster wie benachrichtige_neuer_fehlbestand - fire-
|
||||
and-forget, Versandfehler stören den Prüflauf nicht."""
|
||||
empfaenger = await _empfaenger_fuer_objekt(db)
|
||||
subject = f"Wartung fällig: {wartungsart} ({objekt_name})"
|
||||
body = f"Objekt: {objekt_name}\nWartungsart: {wartungsart}\nObjekt-ID: {objekt_id}\n"
|
||||
|
||||
async def _senden() -> None:
|
||||
try:
|
||||
await send_email(to=empfaenger, subject=subject, body=body)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("E-Mail-Versand für fällige Wartung Objekt %s fehlgeschlagen", objekt_id)
|
||||
|
||||
return asyncio.create_task(_senden())
|
||||
@@ -15,6 +15,7 @@ from app.models.wartung import (
|
||||
Wartungsplan,
|
||||
WartungsplanPosition,
|
||||
)
|
||||
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
|
||||
from app.schemas.wartung import WartungsplanPositionCreate
|
||||
from app.services.datumshilfen import plus_monate
|
||||
|
||||
@@ -119,6 +120,20 @@ async def erledige_wartungsauftrag(
|
||||
auftrag.kilometerstand_bei_erledigung = kilometerstand_bei_erledigung
|
||||
auftrag.betriebsstunden_bei_erledigung = betriebsstunden_bei_erledigung
|
||||
|
||||
# NOTIF-003: die alte Fälligkeit ist erledigt, eine künftige neue
|
||||
# Fälligkeit derselben Position soll wieder eine Benachrichtigung
|
||||
# auslösen dürfen - Tracking-Zeile löschen statt sie stehen zu lassen.
|
||||
bestehende_benachrichtigung = (
|
||||
await db.execute(
|
||||
select(WartungBenachrichtigung).where(
|
||||
WartungBenachrichtigung.objekt_id == auftrag.objekt_id,
|
||||
WartungBenachrichtigung.wartungsplan_position_id == auftrag.wartungsplan_position_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if bestehende_benachrichtigung is not None:
|
||||
await db.delete(bestehende_benachrichtigung)
|
||||
|
||||
await db.flush()
|
||||
return auftrag
|
||||
|
||||
@@ -236,3 +251,17 @@ async def faellige_wartungen_fuer_objekt(db: AsyncSession, objekt: Objekt) -> li
|
||||
)
|
||||
|
||||
return ergebnisse
|
||||
|
||||
|
||||
async def alle_faelligen_wartungen(db: AsyncSession) -> list[dict]:
|
||||
"""NOTIF-003: globale Sicht über alle Objekte für Dashboard-Kachel und
|
||||
Benachrichtigungs-Prüflauf - iteriert `faellige_wartungen_fuer_objekt` je
|
||||
Objekt (keine bestehende Aggregation vorhanden), filtert auf faellig=True."""
|
||||
objekte = (await db.execute(select(Objekt))).scalars().all()
|
||||
ergebnisse: list[dict] = []
|
||||
for objekt in objekte:
|
||||
for eintrag in await faellige_wartungen_fuer_objekt(db, objekt):
|
||||
if eintrag["faellig"]:
|
||||
ergebnisse.append({**eintrag, "objekt_code": objekt.code, "objekt_name": objekt.name})
|
||||
return ergebnisse
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
|
||||
from app.services.benachrichtigung import benachrichtige_faellige_wartung
|
||||
from app.services.wartung import alle_faelligen_wartungen
|
||||
|
||||
|
||||
async def pruefe_faellige_wartungen(db: AsyncSession) -> int:
|
||||
"""NOTIF-003: periodischer Prüflauf (systemd-Timer, analog
|
||||
scripts/eskalation_pruefen.py) - eine Benachrichtigung je
|
||||
(objekt_id, wartungsplan_position_id), nicht mehrfach für dieselbe
|
||||
Fälligkeits-Episode (Tracking-Zeile, gelöscht bei Erledigung siehe
|
||||
app/services/wartung.py:erledige_wartungsauftrag)."""
|
||||
bereits_benachrichtigt = set(
|
||||
(b.objekt_id, b.wartungsplan_position_id)
|
||||
for b in (await db.execute(select(WartungBenachrichtigung))).scalars().all()
|
||||
)
|
||||
|
||||
anzahl_versendet = 0
|
||||
for eintrag in await alle_faelligen_wartungen(db):
|
||||
schluessel = (eintrag["objekt_id"], eintrag["wartungsplan_position_id"])
|
||||
if schluessel in bereits_benachrichtigt:
|
||||
continue
|
||||
await benachrichtige_faellige_wartung(
|
||||
db,
|
||||
objekt_id=eintrag["objekt_id"],
|
||||
objekt_name=eintrag["objekt_name"],
|
||||
wartungsart=eintrag["wartungsart"],
|
||||
)
|
||||
db.add(
|
||||
WartungBenachrichtigung(
|
||||
objekt_id=eintrag["objekt_id"],
|
||||
wartungsplan_position_id=eintrag["wartungsplan_position_id"],
|
||||
benachrichtigt_am=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
anzahl_versendet += 1
|
||||
|
||||
await db.commit()
|
||||
return anzahl_versendet
|
||||
@@ -0,0 +1,29 @@
|
||||
"""NOTIF-003: periodischer Prüflauf für fällige Wartungen, für einen
|
||||
systemd-Timer gedacht (gleiches Muster wie scripts/eskalation_pruefen.py).
|
||||
|
||||
Aufruf (im aktivierten venv auf dem Server):
|
||||
|
||||
python -m scripts.wartung_benachrichtigung_pruefen
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import app.models # noqa: F401 (alle ORM-Modelle registrieren, siehe create_admin.py)
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.wartung_benachrichtigung import pruefe_faellige_wartungen
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with SessionLocal() as db:
|
||||
anzahl = await pruefe_faellige_wartungen(db)
|
||||
logger.info("Wartungsfälligkeits-Prüflauf: %d neue Benachrichtigung(en) versendet.", anzahl)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
|
||||
from app.services.wartung import erstelle_wartungsplan, hole_positionen_mit_intervallen
|
||||
from app.services.wartung_benachrichtigung import pruefe_faellige_wartungen
|
||||
from app.schemas.wartung import WartungsplanPositionCreate
|
||||
|
||||
|
||||
async def _objekt_mit_faelliger_wartung(db_session, hauptserver_id, objekttyp_factory, standort_factory, code):
|
||||
objekttyp = await objekttyp_factory(f"Typ-{code}")
|
||||
standort = await standort_factory(f"Standort-{code}")
|
||||
objekt = Objekt(
|
||||
code=code,
|
||||
name=f"Objekt {code}",
|
||||
objekttyp_id=objekttyp.id,
|
||||
standort_id=standort.id,
|
||||
zustaendiger_server_id=hauptserver_id,
|
||||
)
|
||||
db_session.add(objekt)
|
||||
await db_session.flush()
|
||||
|
||||
plan = await erstelle_wartungsplan(
|
||||
db_session,
|
||||
objekttyp_id=objekttyp.id,
|
||||
name=f"Plan-{code}",
|
||||
positionen=[WartungsplanPositionCreate(wartungsart="Ölwechsel", intervalle=[])],
|
||||
)
|
||||
positionen = await hole_positionen_mit_intervallen(db_session, plan.id)
|
||||
await db_session.commit()
|
||||
return objekt, positionen[0].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faellige_wartung_versendet_einmalig(
|
||||
db_session, admin_user, hauptserver_id, objekttyp_factory, standort_factory
|
||||
):
|
||||
objekt, position_id = await _objekt_mit_faelliger_wartung(
|
||||
db_session, hauptserver_id, objekttyp_factory, standort_factory, "NOTIF-W1"
|
||||
)
|
||||
|
||||
with patch("app.services.benachrichtigung.send_email", new=AsyncMock()) as mock_send:
|
||||
anzahl = await pruefe_faellige_wartungen(db_session)
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.01)
|
||||
if mock_send.await_count:
|
||||
break
|
||||
|
||||
assert anzahl == 1
|
||||
mock_send.assert_awaited_once()
|
||||
assert "Ölwechsel" in mock_send.await_args.kwargs["subject"]
|
||||
|
||||
getrackt = (
|
||||
await db_session.execute(
|
||||
select(WartungBenachrichtigung).where(
|
||||
WartungBenachrichtigung.objekt_id == objekt.id,
|
||||
WartungBenachrichtigung.wartungsplan_position_id == position_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
assert getrackt is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kein_zweiter_versand_fuer_dieselbe_faelligkeit(
|
||||
db_session, admin_user, hauptserver_id, objekttyp_factory, standort_factory
|
||||
):
|
||||
await _objekt_mit_faelliger_wartung(
|
||||
db_session, hauptserver_id, objekttyp_factory, standort_factory, "NOTIF-W2"
|
||||
)
|
||||
|
||||
with patch("app.services.benachrichtigung.send_email", new=AsyncMock()):
|
||||
erste_runde = await pruefe_faellige_wartungen(db_session)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
with patch("app.services.benachrichtigung.send_email", new=AsyncMock()) as mock_send_zweite:
|
||||
zweite_runde = await pruefe_faellige_wartungen(db_session)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert erste_runde == 1
|
||||
assert zweite_runde == 0
|
||||
mock_send_zweite.assert_not_awaited()
|
||||
@@ -19,6 +19,7 @@ import { KennzahlenTile } from "./tiles/KennzahlenTile";
|
||||
import { MaengelTile } from "./tiles/MaengelTile";
|
||||
import { PrueftermineTile } from "./tiles/PrueftermineTile";
|
||||
import { QualifikationTile } from "./tiles/QualifikationTile";
|
||||
import { FaelligeWartungenTile } from "./tiles/FaelligeWartungenTile";
|
||||
import { ReadinessVerlaufTile } from "./tiles/ReadinessVerlaufTile";
|
||||
import type { Ablaufdatumeintrag, Einsatzbereitschaft, Kennzahlen, Prueftermineintrag, Qualifikationsablauf } from "./types";
|
||||
|
||||
@@ -34,6 +35,7 @@ const TITEL: Record<DashboardTileId, string> = {
|
||||
qualifikation: "Qualifikationsablauf",
|
||||
aktivitaet: "Letzte Aktivität",
|
||||
readinessVerlauf: "Bereitschaft im Zeitverlauf",
|
||||
faelligeWartungen: "Fällige Wartungen",
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -201,6 +203,8 @@ export function DashboardPage() {
|
||||
return <AktivitaetTile aktivitaet={aktivitaet} />;
|
||||
case "readinessVerlauf":
|
||||
return <ReadinessVerlaufTile />;
|
||||
case "faelligeWartungen":
|
||||
return <FaelligeWartungenTile />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ export type DashboardTileId =
|
||||
| "maengel"
|
||||
| "qualifikation"
|
||||
| "aktivitaet"
|
||||
| "readinessVerlauf";
|
||||
| "readinessVerlauf"
|
||||
| "faelligeWartungen";
|
||||
|
||||
export const ALLE_TILE_IDS: DashboardTileId[] = [
|
||||
"aufgaben",
|
||||
@@ -19,6 +20,7 @@ export const ALLE_TILE_IDS: DashboardTileId[] = [
|
||||
"qualifikation",
|
||||
"aktivitaet",
|
||||
"readinessVerlauf",
|
||||
"faelligeWartungen",
|
||||
];
|
||||
|
||||
// 24 statt 12 Spalten - feineres Raster, mehr Kontrolle beim Breiter/Schmaler
|
||||
@@ -47,6 +49,7 @@ export const STANDARD_RASTER: Record<DashboardTileId, RasterPosition> = {
|
||||
qualifikation: { x: 16, y: 8, w: 8, h: 4 },
|
||||
aktivitaet: { x: 0, y: 13, w: 16, h: 5 },
|
||||
readinessVerlauf: { x: 16, y: 13, w: 8, h: 5 },
|
||||
faelligeWartungen: { x: 0, y: 18, w: 12, h: 5 },
|
||||
};
|
||||
|
||||
export interface DashboardLayout {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { apiRequest } from "../../../api/client";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
|
||||
interface FaelligeWartung {
|
||||
objekt_id: number;
|
||||
objekt_code: string;
|
||||
objekt_name: string;
|
||||
wartungsplan_position_id: number;
|
||||
wartungsart: string;
|
||||
faellig_am: string | null;
|
||||
faellig_bei_km: number | null;
|
||||
faellig_bei_betriebsstunden: string | null;
|
||||
}
|
||||
|
||||
// NOTIF-003: eigenständiger Fetch (anderer Endpunkt/Daten-Shape als der Rest
|
||||
// des Dashboards, gleiche self-fetching-Bauweise wie ReadinessVerlaufTile).
|
||||
function FaelligeWartungenTileKomponente() {
|
||||
const [eintraege, setEintraege] = useState<FaelligeWartung[] | null>(null);
|
||||
const [fehler, setFehler] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiRequest<FaelligeWartung[]>("/dashboard/faellige-wartungen")
|
||||
.then(setEintraege)
|
||||
.catch(() => setFehler(true));
|
||||
}, []);
|
||||
|
||||
if (fehler) return <p className="text-muted">Konnte nicht geladen werden.</p>;
|
||||
if (eintraege === null) return <p className="text-muted">Lade…</p>;
|
||||
if (eintraege.length === 0) return <EmptyState text="Keine fälligen Wartungen." />;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{eintraege.map((e) => (
|
||||
<li
|
||||
key={`${e.objekt_id}-${e.wartungsplan_position_id}`}
|
||||
className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] py-2 last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/akte/objekt/${e.objekt_id}`} className="block truncate font-medium">
|
||||
{e.wartungsart} ({e.objekt_name})
|
||||
</Link>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{e.faellig_am
|
||||
? `fällig seit/am ${e.faellig_am}`
|
||||
: e.faellig_bei_km !== null
|
||||
? `fällig bei ${e.faellig_bei_km} km`
|
||||
: e.faellig_bei_betriebsstunden !== null
|
||||
? `fällig bei ${e.faellig_bei_betriebsstunden} Bh`
|
||||
: "noch nie durchgeführt"}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const FaelligeWartungenTile = memo(FaelligeWartungenTileKomponente);
|
||||
Reference in new issue
Block a user