Rollen Chunk 6: Team-Tab mit Matrix-Kalender und Genehmigungen
Docker Image / test (push) Canceled after 0s
Docker Image / image (push) Canceled after 0s

This commit is contained in:
loki committed 2026-10-11 20:19:07 +02:00
1 parent 49f8805c5d
commit 54970b97e8
1 file changed
+352
+352
View File
@@ -0,0 +1,352 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
decideVacation,
getTeam,
type TeamMember,
} from "@/lib/api-client";
import { weekdayShort, shortGermanDate } from "@/lib/format";
function pluralDays(units: number): string {
const days = units / 2;
return days === 1 ? "1 Tag" : `${days.toLocaleString("de-DE")} Tage`;
}
function thaiCellTitle(member: TeamMember, date: string): string {
return `${member.name}: ${date}`;
}
interface DayState {
kind: "bestaetigt" | "beantragt" | "krank" | "feiertag" | "wochenende" | "frei";
title: string;
}
function dayState(
member: TeamMember,
date: string,
day: number,
idSuffix: string,
): DayState {
const request =
member.requests.find(
(item) => item.startDate <= date && date <= item.endDate,
) ?? null;
const open =
request !== null &&
member.requests.some(
(item) =>
item.startDate <= date &&
date <= item.endDate &&
item.status === "beantragt",
);
const confirmed =
request !== null &&
member.requests.some(
(item) =>
item.startDate <= date &&
date <= item.endDate &&
item.status === "bestätigt",
);
const sick = member.sickDays.some((day) => day.date === date);
const holiday = member.holidays.includes(date);
const target = (member.weekdayTargets?.[new Date(date).getUTCDay()] ?? 0);
void idSuffix;
if (sick) {
return { kind: "krank", title: `${member.name}: krank, ${date}` };
}
if (open) {
return {
kind: "beantragt",
title: `${member.name}: Urlaub beantragt, ${date}`,
};
}
if (confirmed) {
return { kind: "bestaetigt", title: `${member.name}: Urlaub, ${date}` };
}
if (holiday) {
return { kind: "feiertag", title: `${member.name}: Feiertag, ${date}` };
}
if (target === 0) {
void thaiCellTitle;
return { kind: "wochenende", title: `${member.name}: Wochenende, ${date}` };
}
return { kind: "frei", title: thaiCellTitle(member, date) };
}
function monthMatrix(year: number, month: number): string[] {
const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
return Array.from(
{ length: days },
(_, index) =>
`${year}-${String(month).padStart(2, "0")}-${String(index + 1).padStart(2, "0")}`,
);
}
export function TeamTab({
year,
active,
onReloaded,
initialMonth,
}: {
year: number;
active: boolean;
onReloaded?: () => void;
initialMonth?: number;
}) {
const [team, setTeam] = useState<TeamMember[] | null>(null);
const [month, setMonth] = useState(initialMonth ?? new Date().getUTCMonth() + 1);
const [failed, setFailed] = useState(false);
const [deciding, setDeciding] = useState(false);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
const data = await getTeam(year);
setTeam(data.team);
setFailed(false);
} catch {
setFailed(true);
}
}, [year]);
useEffect(() => {
if (!active) {
return;
}
setTeam(null);
setError(null);
reload();
}, [reload, active]);
async function decide(
id: number,
action: "genehmigen" | "ablehnen",
): Promise<void> {
setDeciding(true);
setError(null);
try {
await decideVacation(id, action);
await reload();
onReloaded?.();
} catch (decideError) {
setError(
decideError instanceof Error
? decideError.message
: "Entscheidung fehlgeschlagen.",
);
} finally {
setDeciding(false);
}
}
if (failed) {
return (
<section aria-label="Team">
<h2>Team</h2>
<p role="alert">Teamdaten konnten nicht geladen werden.</p>
</section>
);
}
if (!team) {
return (
<section aria-label="Team">
<h2>Team</h2>
<p>Wird geladen …</p>
</section>
);
}
const open = team.flatMap((member) =>
member.requests
.filter((request) => request.status === "beantragt")
.map((request) => ({ member, request })),
);
function changeMonth(delta: number) {
setMonth((current) => {
const next = current + delta;
if (next === 0) {
return 12;
}
if (next === 13) {
return 1;
}
return next;
});
}
const days = monthMatrix(year, month);
const unitCell = (state: DayState): string =>
state.kind === "bestaetigt"
? "1"
: state.kind === "krank"
? "1"
: "";
return (
<section aria-label="Team">
<h2>Team</h2>
{open.length > 0 && (
<section aria-label="Offene Anfragen">
<h3>Offene Anfragen ({open.length})</h3>
<ul className="offene-liste">
{open.map(({ member, request }) => (
<li key={`${member.id}-${request.id}`}>
<span>
{member.name}: {shortGermanDate(request.startDate)}
{request.startDate !== request.endDate
? ` – ${shortGermanDate(request.endDate)}`
: ""}{" "}
({pluralDays(request.units)})
</span>
<span>
<button
type="button"
className="btn-primary"
disabled={deciding}
aria-label={`Genehmigen (${member.name}, ${shortGermanDate(request.startDate)}, ${pluralDays(request.units)})`}
onClick={() => decide(request.id, "genehmigen")}
>
Genehmigen
</button>
<button
type="button"
className="btn-danger"
disabled={deciding}
aria-label={`Ablehnen (${member.name}, ${shortGermanDate(request.startDate)}, ${pluralDays(request.units)})`}
onClick={() => decide(request.id, "ablehnen")}
>
Ablehnen
</button>
</span>
</li>
))}
</ul>
</section>
)}
{error && (
<p role="alert" className="form-error">
{error}
</p>
)}
<div className="team-nav">
<button type="button" onClick={() => changeMonth(-1)}>
‹
</button>
<strong>{monthNames[month - 1]}</strong>
<button type="button" onClick={() => changeMonth(1)}>
›
</button>
</div>
<div className="team-matrix-wrap">
<table className="team-matrix">
<thead>
<tr>
<th scope="col">Benutzer</th>
<th scope="col">Rest</th>
{days.map((date) => (
<th scope="col" key={date}>
{Number(date.slice(8, 10))}
</th>
))}
</tr>
</thead>
<tbody>
{team.map((member) => (
<tr key={member.id}>
<th scope="row">
{member.name}
<span className="team-rest">{pluralDays(member.remainingUnits)}</span>
</th>
{days.map((date) => {
const state = dayState(member, date, Number(date.slice(8, 10)), member.id);
return (
<td key={date}>
<span
className={`tcell tcell-${state.kind}`}
title={state.title}
>
{state.kind === "frei" ? "" : unitSymbol(state.kind)}
</span>
</td>
);
})}
</tr>
))}
<tr aria-label="Urlaub">
<th scope="row">Urlaub</th>
{summaryRow(team, days, "urlaub")}
</tr>
<tr aria-label="Beantragt">
<th scope="row">Beantragt</th>
{summaryRow(team, days, "beantragt")}
</tr>
<tr aria-label="Krank">
<th scope="row">Krank</th>
{summaryRow(team, days, "krank")}
</tr>
<tr aria-label="Anwesend">
<th scope="row">Anwesend</th>
{summaryRow(team, days, "anwesend")}
</tr>
</tbody>
</table>
</div>
</section>
);
}
const monthNames = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
function unitSymbol(kind: string): string {
return kind === "bestaetigt"
? "U"
: kind === "krank"
? "K"
: kind === "beantragt"
? "B"
: kind === "feiertag"
? "F"
: "–";
}
function summaryRow(team: TeamMember[], days: string[], kind: string) {
return days.map((date) => {
let count = 0;
for (const member of team) {
const state = dayState(member, date, Number(date.slice(8, 10)), member.id);
if (
(kind === "urlaub" && state.kind === "bestaetigt") ||
(kind === "beantragt" && state.kind === "beantragt") ||
(kind === "krank" && state.kind === "krank")
) {
count += 1;
} else if (kind === "anwesend" && state.kind === "frei") {
count += 1;
}
}
return (
<td
key={date}
className="tcell-summary"
data-summary={`${kind}-${date}`}
>
{count > 0 ? count : ""}
</td>
);
});
}