style(dashboard): Kacheln modernisiert - Karten, Einsatzbereitschaft-Balken, Termin-Listen, Skeleton
Card: 14px Radius, weicherer Schatten, Hover-Lift über box-shadow (nicht transform, würde von react-grid-layouts Inline-Positionierung überschrieben). Einsatzbereitschaft-Kachel: große Zahl "X/Y einsatzbereit", proportionaler gestapelter Statusbalken, Vierer-Zeile mit Farbpunkten+Zahl, Detail-Liste bleibt aufklappbar. Prüftermine/Ablaufdaten/Qualifikationsablauf: auf 5 Zeilen begrenzt mit "Alle anzeigen", Status-Badge links (überfällig gefüllt rot, bald fällig Outline amber, FristBadge zeigt nur noch Status), Countdown rechts tabular-nums. Neue --status-ok/warn/unknown/critical CSS-Variablen als Alias auf bestehende Farb-Tokens. Skeleton-Platzhalter statt "Lade…"-Text während des initialen Ladens. Keine API-/Datenänderungen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -6586,3 +6586,19 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/styles/tailwind.css | 9 ++++++++-
|
- frontend/src/styles/tailwind.css | 9 ++++++++-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-09-07 13:37 – 13:41 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 74ed250 feat(dashboard): echtes freies Grid-Layout mit Resize statt Sortable+Größenstufen
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 13 +++++++++++++
|
||||||
|
- frontend/package-lock.json | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------------------------------
|
||||||
|
- frontend/package.json | 4 +---
|
||||||
|
- frontend/src/pages/dashboard/DashboardPage.tsx | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------
|
||||||
|
- frontend/src/pages/dashboard/TileFrame.tsx | 104 +++++++++++++++++++++++++++++++------------------------------------------------------------------
|
||||||
|
- frontend/src/pages/dashboard/layout.ts | 74 +++++++++++++++++++++++++++++++++------------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -6,26 +6,28 @@ const LABEL: Record<FristStatus, string> = {
|
|||||||
ok: "In Ordnung",
|
ok: "In Ordnung",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Überfällig = auffällig gefüllt, bald fällig = zurückhaltender Outline-Look,
|
||||||
|
// ok = dezente Füllung - Dringlichkeit soll auch ohne Text erkennbar sein.
|
||||||
const KLASSE: Record<FristStatus, string> = {
|
const KLASSE: Record<FristStatus, string> = {
|
||||||
abgelaufen: "badge-danger",
|
abgelaufen: "bg-[var(--status-critical)] text-white",
|
||||||
bald_ablaufend: "badge-warning",
|
bald_ablaufend: "border border-[var(--status-warn)] text-[var(--status-warn)]",
|
||||||
ok: "badge-success",
|
ok: "bg-[var(--status-ok)]/15 text-[var(--status-ok)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: string;
|
status: string;
|
||||||
tage?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** gemeinsame Basis für InspectionStatus/InventoryStatus -
|
/** gemeinsame Basis für InspectionStatus/InventoryStatus -
|
||||||
* beide Backend-Endpunkte (Prüftermine/Ablaufdaten) liefern dieselben zwei
|
* beide Backend-Endpunkte (Prüftermine/Ablaufdaten) liefern dieselben zwei
|
||||||
* Fristzustände (siehe app/services/dashboard.py `_ablauf_status`). */
|
* Fristzustände (siehe app/services/dashboard.py `_ablauf_status`). Zeigt nur
|
||||||
export function FristBadge({ status, tage }: Props) {
|
* den Status - die verbleibenden Tage rendert die jeweilige Kachel separat
|
||||||
|
* als eigenes Countdown-Element. */
|
||||||
|
export function FristBadge({ status }: Props) {
|
||||||
const bekannt = status === "abgelaufen" || status === "bald_ablaufend" ? status : "ok";
|
const bekannt = status === "abgelaufen" || status === "bald_ablaufend" ? status : "ok";
|
||||||
return (
|
return (
|
||||||
<span className={`badge ${KLASSE[bekannt]}`}>
|
<span className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${KLASSE[bekannt]}`}>
|
||||||
{LABEL[bekannt]}
|
{LABEL[bekannt]}
|
||||||
{tage !== undefined && ` (${tage} Tage)`}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FristBadge } from "./FristBadge";
|
import { FristBadge } from "./FristBadge";
|
||||||
|
|
||||||
/** Geräteprüfung-Fristanzeige (Dashboard "Nächste Prüftermine"). */
|
/** Geräteprüfung-Fristanzeige (Dashboard "Nächste Prüftermine"). */
|
||||||
export function InspectionStatus({ status, tage }: { status: string; tage?: number }) {
|
export function InspectionStatus({ status }: { status: string }) {
|
||||||
return <FristBadge status={status} tage={tage} />;
|
return <FristBadge status={status} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FristBadge } from "./FristBadge";
|
import { FristBadge } from "./FristBadge";
|
||||||
|
|
||||||
/** Chargen-/Ablaufdatum-Fristanzeige (Dashboard "Bevorstehende Ablaufdaten"). */
|
/** Chargen-/Ablaufdatum-Fristanzeige (Dashboard "Bevorstehende Ablaufdaten"). */
|
||||||
export function InventoryStatus({ status, tage }: { status: string; tage?: number }) {
|
export function InventoryStatus({ status }: { status: string }) {
|
||||||
return <FristBadge status={status} tage={tage} />;
|
return <FristBadge status={status} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { cn } from "../../lib/utils";
|
|||||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("rounded-lg border border-border bg-surface text-foreground shadow-sm", className)}
|
className={cn(
|
||||||
|
"rounded-[14px] border border-border bg-surface text-foreground",
|
||||||
|
"shadow-[0_1px_3px_rgba(0,0,0,.08),0_4px_12px_rgba(0,0,0,.05)]",
|
||||||
|
// hover-lift über box-shadow statt transform: TileFrame wird von
|
||||||
|
// react-grid-layout per inline transform positioniert, ein Tailwind-
|
||||||
|
// Hover-transform würde von dieser Inline-Positionierung überschrieben.
|
||||||
|
"transition-shadow duration-150 hover:shadow-[0_2px_6px_rgba(0,0,0,.1),0_8px_20px_rgba(0,0,0,.08)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -249,7 +249,13 @@ export function DashboardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{laedt && <p className="text-muted">Lade…</p>}
|
{laedt && (
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-40 animate-pulse rounded-[14px] border border-border bg-surface-sunken" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{bearbeitenModus && layout.ausgeblendet.length > 0 && (
|
{bearbeitenModus && layout.ausgeblendet.length > 0 && (
|
||||||
<div className="mt-3 rounded-md border border-border bg-surface-sunken px-4 py-3 text-sm">
|
<div className="mt-3 rounded-md border border-border bg-surface-sunken px-4 py-3 text-sm">
|
||||||
Ausgeblendet:{" "}
|
Ausgeblendet:{" "}
|
||||||
|
|||||||
@@ -1,30 +1,52 @@
|
|||||||
import { memo } from "react";
|
import { memo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { InventoryStatus } from "../../../components/status/InventoryStatus";
|
import { InventoryStatus } from "../../../components/status/InventoryStatus";
|
||||||
import type { Ablaufdatumeintrag } from "../types";
|
import type { Ablaufdatumeintrag } from "../types";
|
||||||
|
|
||||||
|
const ANZAHL_OHNE_AUFKLAPPEN = 5;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ablaufdaten: Ablaufdatumeintrag[];
|
ablaufdaten: Ablaufdatumeintrag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function AblaufdatenTileKomponente({ ablaufdaten }: Props) {
|
function AblaufdatenTileKomponente({ ablaufdaten }: Props) {
|
||||||
|
const [alleAnzeigen, setAlleAnzeigen] = useState(false);
|
||||||
|
|
||||||
if (ablaufdaten.length === 0) {
|
if (ablaufdaten.length === 0) {
|
||||||
return <p className="text-muted">Keine bald ablaufenden Chargen.</p>;
|
return <p className="text-sm text-muted-foreground">Keine bald ablaufenden Chargen.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sichtbare = alleAnzeigen ? ablaufdaten : ablaufdaten.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="card-list">
|
<>
|
||||||
{ablaufdaten.map((a) => (
|
<ul>
|
||||||
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
|
{sichtbare.map((a) => (
|
||||||
<span>
|
<li
|
||||||
<Link to={`/akte/objekt/${a.objekt_id}`}>{a.objekt_name}</Link> – {a.material_name}{" "}
|
key={`${a.objekt_id}-${a.material_id}`}
|
||||||
<span className="text-muted">({a.ablaufdatum})</span>
|
className="flex items-center gap-3 border-b border-border py-2 last:border-0"
|
||||||
</span>
|
>
|
||||||
<InventoryStatus status={a.status} tage={a.verbleibende_tage} />
|
<InventoryStatus status={a.status} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={`/akte/objekt/${a.objekt_id}`} className="block truncate font-medium">
|
||||||
|
{a.objekt_name} – {a.material_name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">{a.ablaufdatum}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums text-sm font-semibold">{a.verbleibende_tage} Tage</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{ablaufdaten.length > ANZAHL_OHNE_AUFKLAPPEN && (
|
||||||
|
<button
|
||||||
|
className="mt-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setAlleAnzeigen(!alleAnzeigen)}
|
||||||
|
>
|
||||||
|
{alleAnzeigen ? "Weniger anzeigen" : `Alle anzeigen (${ablaufdaten.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ function readinessVonGruenden(gruende: string[]): Readiness {
|
|||||||
return "not-ready";
|
return "not-ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
anzahl: number;
|
||||||
|
farbVar: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bereitschaft: Einsatzbereitschaft;
|
bereitschaft: Einsatzbereitschaft;
|
||||||
}
|
}
|
||||||
@@ -21,45 +27,69 @@ interface Props {
|
|||||||
function EinsatzbereitschaftTileKomponente({ bereitschaft }: Props) {
|
function EinsatzbereitschaftTileKomponente({ bereitschaft }: Props) {
|
||||||
const [detailsOffen, setDetailsOffen] = useState(false);
|
const [detailsOffen, setDetailsOffen] = useState(false);
|
||||||
|
|
||||||
|
const segmente: Segment[] = [
|
||||||
|
{ anzahl: bereitschaft.einsatzbereit, farbVar: "var(--status-ok)", label: "Einsatzbereit" },
|
||||||
|
{ anzahl: bereitschaft.eingeschraenkt_einsatzbereit, farbVar: "var(--status-warn)", label: "Eingeschränkt" },
|
||||||
|
{ anzahl: bereitschaft.unbekannt, farbVar: "var(--status-unknown)", label: "Unbekannt" },
|
||||||
|
{ anzahl: bereitschaft.nicht_einsatzbereit, farbVar: "var(--status-critical)", label: "Nicht bereit" },
|
||||||
|
];
|
||||||
|
const gesamt = bereitschaft.gesamt || 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="stat-number">
|
<p className="leading-none">
|
||||||
{bereitschaft.einsatzbereit}{" "}
|
<span className="text-4xl font-bold tabular-nums">{bereitschaft.einsatzbereit}</span>
|
||||||
<span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text-muted)" }}>
|
<span className="text-sm font-semibold text-muted-foreground">/{bereitschaft.gesamt} einsatzbereit</span>
|
||||||
von {bereitschaft.gesamt} einsatzbereit
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.5rem" }}>
|
|
||||||
<ReadinessBadge status="ready" label={`${bereitschaft.einsatzbereit} einsatzbereit`} />
|
<div className="mt-3 flex h-2.5 w-full overflow-hidden rounded-full bg-surface-sunken">
|
||||||
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
|
{segmente.map(
|
||||||
<ReadinessBadge status="limited" label={`${bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt`} />
|
(s) =>
|
||||||
)}
|
s.anzahl > 0 && (
|
||||||
{bereitschaft.unbekannt > 0 && <ReadinessBadge status="unknown" label={`${bereitschaft.unbekannt} unbekannt`} />}
|
<div
|
||||||
{bereitschaft.nicht_einsatzbereit > 0 && (
|
key={s.label}
|
||||||
<ReadinessBadge status="not-ready" label={`${bereitschaft.nicht_einsatzbereit} nicht bereit`} />
|
style={{ width: `${(s.anzahl / gesamt) * 100}%`, backgroundColor: s.farbVar }}
|
||||||
|
title={`${s.label}: ${s.anzahl}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1.5 sm:grid-cols-4">
|
||||||
|
{segmente.map((s) => (
|
||||||
|
<div key={s.label} className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: s.farbVar }} />
|
||||||
|
<span className="text-muted-foreground">{s.label}</span>
|
||||||
|
<span className="ml-auto font-semibold tabular-nums">{s.anzahl}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{bereitschaft.nie_kontrolliert_gesamt > bereitschaft.unbekannt && (
|
{bereitschaft.nie_kontrolliert_gesamt > bereitschaft.unbekannt && (
|
||||||
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
Davon {bereitschaft.nie_kontrolliert_gesamt} insgesamt noch nie kontrolliert (auch bei Objekten mit weiteren
|
Davon {bereitschaft.nie_kontrolliert_gesamt} insgesamt noch nie kontrolliert (auch bei Objekten mit weiteren
|
||||||
Problemen).
|
Problemen).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bereitschaft.details.length > 0 && (
|
{bereitschaft.details.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
|
<button
|
||||||
|
className="mt-3 text-sm font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setDetailsOffen(!detailsOffen)}
|
||||||
|
>
|
||||||
{detailsOffen ? "Details ausblenden" : "Details anzeigen"}
|
{detailsOffen ? "Details ausblenden" : "Details anzeigen"}
|
||||||
</button>
|
</button>
|
||||||
{detailsOffen && (
|
{detailsOffen && (
|
||||||
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
|
<ul className="mt-2">
|
||||||
{bereitschaft.details.map((d) => (
|
{bereitschaft.details.map((d) => (
|
||||||
<li key={d.objekt_id} className="row-between" style={{ padding: "0.3rem 0" }}>
|
<li key={d.objekt_id} className="flex items-center justify-between gap-2 border-b border-border py-2 last:border-0">
|
||||||
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
|
<Link to={`/objekte/${d.objekt_id}/kontrolle`} className="truncate font-medium">
|
||||||
{d.name} ({d.code})
|
{d.name} ({d.code})
|
||||||
</Link>
|
</Link>
|
||||||
<span className="row" style={{ gap: "0.4rem" }}>
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
<ReadinessBadge status={readinessVonGruenden(d.gruende)} />
|
<ReadinessBadge status={readinessVonGruenden(d.gruende)} />
|
||||||
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
|
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||||
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
|
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,30 +1,52 @@
|
|||||||
|
import { memo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
import { InspectionStatus } from "../../../components/status/InspectionStatus";
|
import { InspectionStatus } from "../../../components/status/InspectionStatus";
|
||||||
import type { Prueftermineintrag } from "../types";
|
import type { Prueftermineintrag } from "../types";
|
||||||
|
|
||||||
|
const ANZAHL_OHNE_AUFKLAPPEN = 5;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prueftermine: Prueftermineintrag[];
|
prueftermine: Prueftermineintrag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrueftermineTileKomponente({ prueftermine }: Props) {
|
function PrueftermineTileKomponente({ prueftermine }: Props) {
|
||||||
|
const [alleAnzeigen, setAlleAnzeigen] = useState(false);
|
||||||
|
|
||||||
if (prueftermine.length === 0) {
|
if (prueftermine.length === 0) {
|
||||||
return <p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>;
|
return <p className="text-sm text-muted-foreground">Keine anstehenden oder überfälligen Geräteprüfungen.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sichtbare = alleAnzeigen ? prueftermine : prueftermine.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="card-list">
|
<>
|
||||||
{prueftermine.map((p) => (
|
<ul>
|
||||||
<li key={`${p.typ}-${p.objekt_id}-${p.faelligkeitsdatum}`} className="row-between" style={{ padding: "0.3rem 0" }}>
|
{sichtbare.map((p) => (
|
||||||
<span>
|
<li
|
||||||
<Link to={`/akte/objekt/${p.objekt_id}`}>{p.bezeichnung}</Link>{" "}
|
key={`${p.typ}-${p.objekt_id}-${p.faelligkeitsdatum}`}
|
||||||
<span className="text-muted">({p.faelligkeitsdatum})</span>
|
className="flex items-center gap-3 border-b border-border py-2 last:border-0"
|
||||||
</span>
|
>
|
||||||
<InspectionStatus status={p.status} tage={p.verbleibende_tage} />
|
<InspectionStatus status={p.status} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={`/akte/objekt/${p.objekt_id}`} className="block truncate font-medium">
|
||||||
|
{p.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">{p.faelligkeitsdatum}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums text-sm font-semibold">{p.verbleibende_tage} Tage</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{prueftermine.length > ANZAHL_OHNE_AUFKLAPPEN && (
|
||||||
|
<button
|
||||||
|
className="mt-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setAlleAnzeigen(!alleAnzeigen)}
|
||||||
|
>
|
||||||
|
{alleAnzeigen ? "Weniger anzeigen" : `Alle anzeigen (${prueftermine.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
import { memo } from "react";
|
import { memo, useState } from "react";
|
||||||
|
|
||||||
|
import { FristBadge } from "../../../components/status/FristBadge";
|
||||||
import type { Qualifikationsablauf } from "../types";
|
import type { Qualifikationsablauf } from "../types";
|
||||||
|
|
||||||
|
const ANZAHL_OHNE_AUFKLAPPEN = 5;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
qualifikationsablaeufe: Qualifikationsablauf[];
|
qualifikationsablaeufe: Qualifikationsablauf[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function QualifikationTileKomponente({ qualifikationsablaeufe }: Props) {
|
function QualifikationTileKomponente({ qualifikationsablaeufe }: Props) {
|
||||||
|
const [alleAnzeigen, setAlleAnzeigen] = useState(false);
|
||||||
|
|
||||||
if (qualifikationsablaeufe.length === 0) {
|
if (qualifikationsablaeufe.length === 0) {
|
||||||
return <p className="text-muted">Keine bald ablaufenden Qualifikationen.</p>;
|
return <p className="text-sm text-muted-foreground">Keine bald ablaufenden Qualifikationen.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sichtbare = alleAnzeigen ? qualifikationsablaeufe : qualifikationsablaeufe.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="card-list">
|
<>
|
||||||
{qualifikationsablaeufe.map((q) => (
|
<ul>
|
||||||
<li key={q.benutzer_qualifikation_id} className="row-between" style={{ padding: "0.3rem 0" }}>
|
{sichtbare.map((q) => (
|
||||||
<span>
|
<li key={q.benutzer_qualifikation_id} className="flex items-center gap-3 border-b border-border py-2 last:border-0">
|
||||||
{q.benutzer_name} <span className="text-muted">({q.qualifikationstyp_name})</span>
|
<FristBadge status={q.status} />
|
||||||
</span>
|
<div className="min-w-0 flex-1">
|
||||||
<span style={{ color: q.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
|
<span className="block truncate font-medium">{q.benutzer_name}</span>
|
||||||
{q.gueltig_bis} ({q.verbleibende_tage} Tage)
|
<span className="text-xs text-muted-foreground">{q.qualifikationstyp_name}</span>
|
||||||
</span>
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums text-sm font-semibold">{q.verbleibende_tage} Tage</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{qualifikationsablaeufe.length > ANZAHL_OHNE_AUFKLAPPEN && (
|
||||||
|
<button
|
||||||
|
className="mt-2 text-sm font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setAlleAnzeigen(!alleAnzeigen)}
|
||||||
|
>
|
||||||
|
{alleAnzeigen ? "Weniger anzeigen" : `Alle anzeigen (${qualifikationsablaeufe.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
--color-not-ready-bg: #fbeaea;
|
--color-not-ready-bg: #fbeaea;
|
||||||
--color-unknown: #55606b;
|
--color-unknown: #55606b;
|
||||||
--color-unknown-bg: #e9ebee;
|
--color-unknown-bg: #e9ebee;
|
||||||
|
/* Dashboard-Redesign: einheitliche Statusfarben-Namen für Kacheln
|
||||||
|
(ok/warn/unknown/critical), als Alias auf bestehende Tokens - ändern sich
|
||||||
|
automatisch mit, wenn Light/Dark die --color-ready/... Werte neu setzen. */
|
||||||
|
--status-ok: var(--color-ready);
|
||||||
|
--status-warn: var(--color-limited);
|
||||||
|
--status-unknown: var(--color-unknown);
|
||||||
|
--status-critical: var(--color-not-ready);
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--shadow-sm: none;
|
--shadow-sm: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user